diff --git a/.claude/commands/add-missing-i18n.md b/.claude/commands/add-missing-i18n.md index 4b8414189..b88a2005e 100644 --- a/.claude/commands/add-missing-i18n.md +++ b/.claude/commands/add-missing-i18n.md @@ -3,12 +3,15 @@ ## Task: Add English translations for all new localized strings ### Step 1: Identify new translation keys + Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions. ### Step 2: Add translations to English locale file + For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`. ### Key-to-JSON mapping rules: + - Translation keys use dot notation to represent nested JSON structure - Convert dot notation to nested JSON objects when adding to the locale file - Example: The key `g.user.name` maps to: @@ -23,6 +26,7 @@ For each new translation key found, add the corresponding English text to the fi ``` ### Important notes: + 1. **Only modify the English locale file** (`src/locales/en/main.json`) 2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow 3. **Exception for manual translations**: Only add translations to non-English locale files if: @@ -30,6 +34,7 @@ For each new translation key found, add the corresponding English text to the fi - The automated translation would likely be incorrect due to technical terminology or context-specific meaning ### Example workflow: + 1. If you added `t('settings.advanced.enable')` in a Vue component 2. Add to `src/locales/en/main.json`: ```json diff --git a/.claude/commands/comprehensive-pr-review.md b/.claude/commands/comprehensive-pr-review.md index 1b4047e78..b801fff9e 100644 --- a/.claude/commands/comprehensive-pr-review.md +++ b/.claude/commands/comprehensive-pr-review.md @@ -15,6 +15,7 @@ To post inline comments, you will use the GitHub API via the `gh` command. Here' - Run: `gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'` to get the latest commit SHA 2. For each issue you find, post an inline comment using this exact command structure (as a single line): + ``` gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="YOUR_COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT" ``` @@ -22,13 +23,15 @@ To post inline comments, you will use the GitHub API via the `gh` command. Here' 3. Format your comment body using actual newlines in the command. Use a heredoc or construct the body with proper line breaks: ``` COMMENT_BODY="**[category] severity Priority** + ``` **Issue**: Brief description of the problem **Context**: Why this matters **Suggestion**: How to fix it" - ``` - - Then use: `-f body="$COMMENT_BODY"` + +``` + +Then use: `-f body="$COMMENT_BODY"` ## Phase 1: Environment Setup and PR Context @@ -58,10 +61,12 @@ This is critical for better file inspection: 1. Get PR metadata: `gh pr view $PR_NUMBER --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json` 2. Extract branch names from pr_info.json using jq 3. Fetch and checkout the PR branch: - ``` - git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER" - git checkout "pr-$PR_NUMBER" - ``` +``` + +git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER" +git checkout "pr-$PR_NUMBER" + +``` ### Step 1.4: Get Changed Files and Diffs @@ -100,9 +105,9 @@ Intelligently load only relevant knowledge: 1. Use GitHub API to discover available knowledge folders: `https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge` 2. For each knowledge folder, check if it's relevant by searching for the folder name in: - - Changed file paths - - PR title - - PR body +- Changed file paths +- PR title +- PR body 3. If relevant, download all files from that knowledge folder ### Step 2.4: Load Validation Rules @@ -193,12 +198,14 @@ Consider: For each issue found, create a concise inline comment with this structure: ``` + **[category] severity Priority** **Issue**: Brief description of the problem **Context**: Why this matters **Suggestion**: How to fix it -``` + +```` Categories: architecture/security/performance/quality Severities: critical/high/medium/low @@ -214,7 +221,7 @@ For EACH issue: ```bash gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT" -``` +```` CRITICAL: The entire command must be on one line. Use actual values, not placeholders. @@ -223,12 +230,14 @@ CRITICAL: The entire command must be on one line. Use actual values, not placeho Here's an example of how to review a file with a security issue: 1. First, get the repository info: + ```bash gh repo view --json owner,name # Output: {"owner":{"login":"Comfy-Org"},"name":"ComfyUI_frontend"} ``` 2. Get the commit SHA: + ```bash gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid' # Output: abc123def456 @@ -240,14 +249,17 @@ Here's an example of how to review a file with a security issue: ```bash # First, create the comment body with proper newlines COMMENT_BODY="**[security] critical Priority** + ``` **Issue**: SQL injection vulnerability - user input directly concatenated into query **Context**: Allows attackers to execute arbitrary SQL commands **Suggestion**: Use parameterized queries or prepared statements" - - # Then post the comment (as a single line) - gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/Comfy-Org/ComfyUI_frontend/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="abc123def456" -f path="src/db/queries.js" -F line=42 -f side="RIGHT" - ``` + +# Then post the comment (as a single line) + +gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/Comfy-Org/ComfyUI_frontend/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="abc123def456" -f path="src/db/queries.js" -F line=42 -f side="RIGHT" + +``` Repeat this process for every issue you find in the PR. @@ -282,9 +294,9 @@ Analyze the PR to determine its type: 1. Extract PR title and body from pr_info.json 2. Count files, additions, and deletions 3. Determine PR type: - - Feature: Check for tests, documentation, backward compatibility - - Bug fix: Verify root cause addressed, includes regression tests - - Refactor: Ensure behavior preservation, tests still pass +- Feature: Check for tests, documentation, backward compatibility +- Bug fix: Verify root cause addressed, includes regression tests +- Refactor: Ensure behavior preservation, tests still pass ## Phase 7: Generate Comprehensive Summary @@ -292,16 +304,17 @@ After ALL inline comments are posted, create a summary: 1. Calculate total issues by category and severity 2. Use `gh pr review $PR_NUMBER --comment` to post a summary with: - - Review disclaimer - - Issue distribution (counts by severity) - - Category breakdown - - Key findings for each category - - Positive observations - - References to guidelines - - Next steps +- Review disclaimer +- Issue distribution (counts by severity) +- Category breakdown +- Key findings for each category +- Positive observations +- References to guidelines +- Next steps Include in the summary: ``` + # Comprehensive PR Review This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another. @@ -312,12 +325,14 @@ This review is generated by Claude. It may not always be accurate, as with human **Impact**: [X] additions, [Y] deletions across [Z] 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 @@ -326,33 +341,42 @@ This review is generated by Claude. It may not always be accurate, as with human ## 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.* + +_This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review._ + ``` ## Important Guidelines @@ -375,4 +399,5 @@ This is a COMPREHENSIVE review, not a linting pass. Provide the same quality fee 5. Phase 6: Consider PR type for additional checks 6. Phase 7: Post comprehensive summary ONLY after all inline comments -Remember: Individual inline comments for each issue, then one final summary. Never batch issues into a single comment. \ No newline at end of file +Remember: Individual inline comments for each issue, then one final summary. Never batch issues into a single comment. +``` diff --git a/.claude/commands/create-frontend-release.md b/.claude/commands/create-frontend-release.md index f16189d42..ae2240462 100644 --- a/.claude/commands/create-frontend-release.md +++ b/.claude/commands/create-frontend-release.md @@ -7,8 +7,9 @@ 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 +- `minor` - New features, backward compatible - `major` - Breaking changes - `prerelease` - Alpha/beta/rc releases - `patch "Critical security fixes"` - With custom description @@ -21,8 +22,9 @@ If no arguments provided, the command will always perform prerelease if the curr ## Prerequisites Before starting, ensure: + - You have push access to the repository -- GitHub CLI (`gh`) is authenticated +- 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 @@ -30,6 +32,7 @@ Before starting, ensure: ## 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") @@ -40,6 +43,7 @@ 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) @@ -49,6 +53,7 @@ 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 @@ -129,13 +134,14 @@ echo "Last stable release: $LAST_STABLE" ### Step 4: Analyze Dependency Updates 1. **Use pnpm's built-in dependency analysis:** + ```bash # Get outdated dependencies with pnpm pnpm outdated --format table > outdated-deps-${NEW_VERSION}.txt - + # Check for license compliance pnpm licenses ls --json > licenses-${NEW_VERSION}.json - + # Analyze why specific dependencies exist echo "Dependency analysis:" > dep-analysis-${NEW_VERSION}.md MAJOR_DEPS=("vue" "vite" "@vitejs/plugin-vue" "typescript" "pinia") @@ -147,22 +153,23 @@ echo "Last stable release: $LAST_STABLE" ``` 2. **Check for significant dependency updates:** + ```bash # Extract all dependency changes for major version bumps OTHER_DEP_CHANGES="" - + # Compare major dependency versions (you can extend this list) MAJOR_DEPS=("vue" "vite" "@vitejs/plugin-vue" "typescript" "pinia") - + for dep in "${MAJOR_DEPS[@]}"; do PREV_VER=$(echo "$PREV_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "") CURR_VER=$(echo "$CURRENT_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "") - + if [ "$PREV_VER" != "$CURR_VER" ] && [ -n "$PREV_VER" ] && [ -n "$CURR_VER" ]; then # Check if it's a major version change PREV_MAJOR=$(echo "$PREV_VER" | cut -d. -f1 | sed 's/[^0-9]//g') CURR_MAJOR=$(echo "$CURR_VER" | cut -d. -f1 | sed 's/[^0-9]//g') - + if [ "$PREV_MAJOR" != "$CURR_MAJOR" ]; then OTHER_DEP_CHANGES="${OTHER_DEP_CHANGES}\n- **${dep}**: ${PREV_VER} → ${CURR_VER} (Major version change)" fi @@ -173,11 +180,12 @@ echo "Last stable release: $LAST_STABLE" ### Step 5: Generate GTM Feature Summary 1. **Collect PR data for analysis:** + ```bash # Get list of PR numbers from commits PR_NUMBERS=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | \ grep -oE "#[0-9]+" | tr -d '#' | sort -u) - + # Save PR data for each PR echo "[" > prs-${NEW_VERSION}.json first=true @@ -189,16 +197,17 @@ echo "Last stable release: $LAST_STABLE" ``` 2. **Analyze for GTM-worthy features:** + ``` Review these PRs to identify features worthy of marketing attention. - + A feature is GTM-worthy if it meets ALL of these criteria: - Introduces a NEW capability users didn't have before (not just improvements) - Would be a compelling reason for users to upgrade to this version - Can be demonstrated visually or has clear before/after comparison - Affects a significant portion of the user base - + NOT GTM-worthy: - Bug fixes (even important ones) - Minor UI tweaks or color changes @@ -206,19 +215,20 @@ echo "Last stable release: $LAST_STABLE" - Internal refactoring - Small convenience features - Features that only improve existing functionality marginally - + For each GTM-worthy feature, note: - PR number, title, and author - Media links from the PR description - One compelling sentence on why users should care - + If there are no GTM-worthy features, just say "No marketing-worthy features in this release." - + PR data: [contents of prs-${NEW_VERSION}.json] ``` 3. **Generate GTM notification using this EXACT Slack-compatible format:** + ```bash # Only create file if GTM-worthy features exist: if [ "$GTM_FEATURES_FOUND" = "true" ]; then @@ -252,8 +262,8 @@ echo "Last stable release: $LAST_STABLE" ``` **CRITICAL Formatting Requirements:** - - Use single asterisk (*) for emphasis, NOT double (**) - - Use underscore (_) for italics + - Use single asterisk (\*) for emphasis, NOT double (\*\*) + - Use underscore (\_) for italics - Use 4 spaces for indentation (not tabs) - Convert author names to @username format (e.g., "John Smith" → "@john") - No section headers (#), no code language specifications @@ -263,6 +273,7 @@ echo "Last stable release: $LAST_STABLE" ### Step 6: Version Preview **Version Preview:** + - Current: `${CURRENT_VERSION}` - Proposed: Show exact version number based on analysis: - Major version if breaking changes detected @@ -326,6 +337,7 @@ echo "Last stable release: $LAST_STABLE" done ``` 3. Create standardized release notes using this exact template: + ```bash cat > release-notes-${NEW_VERSION}.md << 'EOF' ## ⚠️ Breaking Changes @@ -359,6 +371,7 @@ echo "Last stable release: $LAST_STABLE" **Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/${BASE_TAG}...v${NEW_VERSION} EOF ``` + 4. **Parse commits and populate template:** - Group commits by conventional commit type (feat:, fix:, chore:, etc.) - Extract PR numbers from commit messages @@ -375,6 +388,7 @@ echo "Last stable release: $LAST_STABLE" ### Step 10: 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} @@ -384,7 +398,9 @@ 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 @@ -394,23 +410,25 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 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 release-notes-${NEW_VERSION}.md \ --label "Release" ``` + 3. **Update PR with release notes:** ```bash # For workflow-created PRs, update the body with our release notes @@ -468,14 +486,14 @@ echo "Workflow triggered. Waiting for PR creation..." # Monitor branch creation (for minor/major releases) gh run list --workflow=release-branch-create.yaml --limit=1 ``` -4. If workflow didn't trigger due to [skip ci]: +5. 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: +6. If workflow triggered, monitor execution: ```bash WORKFLOW_RUN_ID=$(gh run list --workflow=release-draft-create.yaml --limit=1 --json databaseId --jq '.[0].databaseId') gh run watch ${WORKFLOW_RUN_ID} @@ -484,6 +502,7 @@ echo "Workflow triggered. Waiting for PR creation..." ### 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 @@ -493,13 +512,14 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 2. **Enhance the GitHub release:** + ```bash # Update release with our release notes gh release edit v${NEW_VERSION} \ --title "🚀 ComfyUI Frontend v${NEW_VERSION}" \ --notes-file release-notes-${NEW_VERSION}.md \ --latest - + # Add any additional assets if needed # gh release upload v${NEW_VERSION} additional-assets.zip ``` @@ -512,14 +532,17 @@ echo "Workflow triggered. Waiting for PR creation..." ### 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 @@ -533,6 +556,7 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 3. **npm Package:** + ```bash # Check npm availability for i in {1..10}; do @@ -550,15 +574,17 @@ echo "Workflow triggered. Waiting for PR creation..." ### 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} @@ -589,6 +615,7 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 3. **Create release summary:** + ```bash cat > release-summary-${NEW_VERSION}.md << EOF # Release Summary: ComfyUI Frontend v${NEW_VERSION} @@ -626,6 +653,7 @@ echo "Workflow triggered. Waiting for PR creation..." ### Step 17: Create Release Summary 1. **Create comprehensive release summary:** + ```bash cat > release-summary-${NEW_VERSION}.md << EOF # Release Summary: ComfyUI Frontend v${NEW_VERSION} @@ -665,6 +693,7 @@ echo "Workflow triggered. Waiting for PR creation..." ### Rollback Procedures **Pre-Merge Rollback:** + ```bash # Close version bump PR and reset gh pr close ${PR_NUMBER} @@ -673,6 +702,7 @@ git clean -fd ``` **Post-Merge Rollback:** + ```bash # Create immediate patch release with reverts git revert ${RELEASE_COMMIT} @@ -680,6 +710,7 @@ git revert ${RELEASE_COMMIT} ``` **Emergency Procedures:** + ```bash # Document incident cat > release-incident-${NEW_VERSION}.md << EOF @@ -713,31 +744,39 @@ The command implements multiple quality gates: ## 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 @@ -747,10 +786,12 @@ The command implements multiple quality gates: When executing this release process, pay attention to these key aspects: ### Version Handling + - For pre-release versions (e.g., 1.24.0-rc.1), the next stable release should be the same version without the suffix (1.24.0) - Never skip version numbers - follow semantic versioning strictly ### Commit History Analysis + - **ALWAYS** use `--first-parent` flag with git log to avoid including commits from merged feature branches - Verify PR merge targets before including them in changelogs: ```bash @@ -758,6 +799,7 @@ When executing this release process, pay attention to these key aspects: ``` ### Release Workflow Triggers + - The "Release" label on the PR is **CRITICAL** - without it, PyPI/npm publishing won't occur - Check for `[skip ci]` in commit messages before merging - this blocks the release workflow - If you encounter `[skip ci]`, push an empty commit to override it: @@ -766,11 +808,13 @@ When executing this release process, pay attention to these key aspects: ``` ### PR Creation Details + - Version bump PRs come from `comfy-pr-bot`, not `github-actions` - The workflow typically completes in 20-30 seconds - Always wait for the PR to be created before trying to edit it ### Breaking Changes Detection + - Analyze changes to public-facing APIs: - The `app` object and its methods - The `api` module exports @@ -779,9 +823,10 @@ When executing this release process, pay attention to these key aspects: - Any modifications to these require marking as breaking changes ### Recovery Procedures + If the release workflow fails to trigger: + 1. Create a revert PR to restore the previous version 2. Merge the revert 3. Re-run the version bump workflow 4. This approach is cleaner than creating extra version numbers - diff --git a/.claude/commands/create-hotfix-release.md b/.claude/commands/create-hotfix-release.md index cc9c37ef5..8c68ef888 100644 --- a/.claude/commands/create-hotfix-release.md +++ b/.claude/commands/create-hotfix-release.md @@ -3,10 +3,11 @@ This command creates patch/hotfix releases for ComfyUI Frontend by backporting fixes to stable core branches. It handles both automated backports (preferred) and manual cherry-picking (fallback). **Process Overview:** + 1. **Check automated backports first** (via labels) 2. **Skip to version bump** if backports already merged 3. **Manual cherry-picking** if automation failed -4. **Create patch release** with version bump +4. **Create patch release** with version bump 5. **Publish GitHub release** (manually uncheck "latest") 6. **Update ComfyUI requirements.txt** via PR @@ -14,7 +15,8 @@ This command creates patch/hotfix releases for ComfyUI Frontend by backporting f Create a hotfix release by backporting commits/PRs from main to a core branch: $ARGUMENTS Expected format: Comma-separated list of commits or PR numbers -Examples: +Examples: + - `#1234,#5678` (PRs - preferred) - `abc123,def456` (commit hashes) - `#1234,abc123` (mixed) @@ -25,7 +27,7 @@ If no arguments provided, the command will guide you through identifying commits ## Prerequisites - Push access to repository -- GitHub CLI (`gh`) authenticated +- GitHub CLI (`gh`) authenticated - Clean working tree - Understanding of what fixes need backporting @@ -36,11 +38,13 @@ If no arguments provided, the command will guide you through identifying commits **Check if automated backports were attempted:** 1. **For each PR, check existing backport labels:** + ```bash gh pr view #1234 --json labels | jq -r '.labels[].name' ``` 2. **If no backport labels exist, add them now:** + ```bash # Add backport labels (this triggers automated backports) gh pr edit #1234 --add-label "needs-backport" @@ -48,6 +52,7 @@ If no arguments provided, the command will guide you through identifying commits ``` 3. **Check for existing backport PRs:** + ```bash # Check for backport PRs created by automation PR_NUMBER=${ARGUMENTS%%,*} # Extract first PR number from arguments @@ -58,18 +63,22 @@ If no arguments provided, the command will guide you through identifying commits 4. **Handle existing backport scenarios:** **Scenario A: Automated backports already merged** + ```bash # Check if backport PRs were merged to core branches gh pr list --search "backport-${PR_NUMBER}-to" --state merged ``` + - If backport PRs are merged → Skip to Step 10 (Version Bump) - **CONFIRMATION**: Automated backports completed, proceeding to version bump? **Scenario B: Automated backport PRs exist but not merged** + ```bash # Show open backport PRs that need merging gh pr list --search "backport-${PR_NUMBER}-to" --state open ``` + - **ACTION REQUIRED**: Merge the existing backport PRs first - Use: `gh pr merge [PR_NUMBER] --merge` for each backport PR - After merging, return to this command and skip to Step 10 (Version Bump) @@ -127,6 +136,7 @@ If no arguments provided, the command will guide you through identifying commits ### Step 6: Cherry-pick Changes For each commit: + 1. Attempt cherry-pick: `git cherry-pick ` 2. If conflicts occur: - Display conflict details @@ -198,6 +208,7 @@ For each commit: ``` 3. **CRITICAL**: Verify "Release" label is added 4. Create standardized release notes: + ```bash cat > release-notes-${NEW_VERSION}.md << 'EOF' ## ⚠️ Breaking Changes @@ -231,12 +242,14 @@ For each commit: **Full Changelog**: https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${CURRENT_VERSION}...v${NEW_VERSION} EOF ``` + - For hotfixes, typically only populate the "Bug Fixes" section - Include links to the cherry-picked PRs/commits - Update the PR body with the release notes: ```bash gh pr edit ${PR_NUMBER} --body-file release-notes-${NEW_VERSION}.md ``` + 5. **CONFIRMATION REQUIRED**: Release PR has "Release" label? ### Step 12: Monitor Release Process @@ -262,7 +275,7 @@ For each commit: 2. **Find the DRAFT release** (e.g., "v1.23.5 Draft") 3. **Click "Edit release"** 4. **UNCHECK "Set as the latest release"** ⚠️ **CRITICAL** - - This prevents the hotfix from showing as "latest" + - This prevents the hotfix from showing as "latest" - Main branch should always be "latest release" 5. **Click "Publish release"** 6. **CONFIRMATION REQUIRED**: Draft release published with "latest" unchecked? @@ -272,6 +285,7 @@ For each commit: **IMPORTANT**: Create PR to update ComfyUI's requirements.txt via fork: 1. **Setup fork (if needed):** + ```bash # Check if fork already exists if gh repo view ComfyUI --json owner | jq -r '.owner.login' | grep -q "$(gh api user --jq .login)"; then @@ -284,30 +298,32 @@ For each commit: ``` 2. **Clone fork and create branch:** + ```bash # Clone your fork (or use existing clone) GITHUB_USER=$(gh api user --jq .login) if [ ! -d "ComfyUI-fork" ]; then gh repo clone ${GITHUB_USER}/ComfyUI ComfyUI-fork fi - + cd ComfyUI-fork git checkout master git pull origin master - + # Create update branch BRANCH_NAME="update-frontend-${NEW_VERSION}" git checkout -b ${BRANCH_NAME} ``` 3. **Update requirements.txt:** + ```bash # Update the version in requirements.txt sed -i "s/comfyui-frontend-package==[0-9].*$/comfyui-frontend-package==${NEW_VERSION}/" requirements.txt - + # Verify the change grep "comfyui-frontend-package" requirements.txt - + # Commit the change git add requirements.txt git commit -m "Bump frontend to ${NEW_VERSION}" @@ -321,7 +337,8 @@ For each commit: --repo comfyanonymous/ComfyUI \ --title "Bump frontend to ${NEW_VERSION}" \ --body "$(cat < /dev/null 2>&1; then echo "✅ Development server started successfully at http://localhost:5173" - + # Kill the background server kill $SERVER_PID wait $SERVER_PID 2>/dev/null @@ -122,7 +123,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" @@ -154,4 +155,4 @@ After running the setup, manually verify: - Node.js >= 24 - Git repository - Internet connection for package downloads -- Available ports (typically 5173 for dev server) \ No newline at end of file +- Available ports (typically 5173 for dev server) diff --git a/.claude/commands/verify-visually.md b/.claude/commands/verify-visually.md index 66260b159..7258d4b11 100644 --- a/.claude/commands/verify-visually.md +++ b/.claude/commands/verify-visually.md @@ -12,10 +12,10 @@ Follow these steps systematically to verify our changes: 2. **Visual Testing Process** - Navigate to http://localhost:5173/ - For each target page (specified in arguments or recently changed files): - * Navigate to the page using direct URL or site navigation - * Take a high-quality screenshot - * Analyze the screenshot for the specific changes we implemented - * Document any visual issues or improvements needed + - Navigate to the page using direct URL or site navigation + - Take a high-quality screenshot + - Analyze the screenshot for the specific changes we implemented + - Document any visual issues or improvements needed 3. **Quality Verification** Check each page for: @@ -27,7 +27,7 @@ Follow these steps systematically to verify our changes: - Typography and readability - Color scheme consistency - Interactive elements (buttons, links, forms) - + Common issues to watch for: @@ -48,10 +48,11 @@ For each page tested, provide: 4. Overall assessment of visual quality If you find issues, be specific about: + - Exact location of the problem - Expected vs actual behavior - Severity level (critical, important, minor) - Suggested fix if obvious - + -Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance. \ No newline at end of file +Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance. diff --git a/.cursor/rules/unit-test.mdc b/.cursor/rules/unit-test.mdc deleted file mode 100644 index 2c6704f3e..000000000 --- a/.cursor/rules/unit-test.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Creating unit tests -globs: -alwaysApply: false ---- - -# Creating unit tests - -- This project uses `vitest` for unit testing -- Tests are stored in the `test/` directory -- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux - - e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms -- Tests should be mocked properly - - Mocks should be cleanly written and easy to understand - - Mocks should be re-usable where possible - -## Unit test style - -- Prefer the use of `test.extend` over loose variables - - To achieve this, import `test as baseTest` from `vitest` -- Never use `it`; `test` should be used in place of this \ No newline at end of file diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..36ee825b4 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,15 @@ +# PR Review Context + +Context for automated PR review system. + +## Review Scope + +This automated review performs comprehensive analysis: + +- Architecture and design patterns +- Security vulnerabilities +- Performance implications +- Code quality and maintainability +- Integration concerns + +For implementation details, see `.claude/commands/comprehensive-pr-review.md`. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 9a95d8cd0..fc844bc41 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -1,36 +1,4 @@ -# 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`. \ No newline at end of file +@AGENTS.md diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 2ed7cf206..f59926d26 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -60,7 +60,7 @@ body: attributes: label: ComfyUI Frontend Version description: Found in Settings > About (e.g., "1.3.45") - placeholder: "1.3.45" + placeholder: '1.3.45' validations: required: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 551b03721..14874c099 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,4 +34,4 @@ Follow Vue 3 style guide and naming conventions Use Vite for fast development and building -Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. \ No newline at end of file +Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. diff --git a/.github/workflows/ci-json-validation.yaml b/.github/workflows/ci-json-validation.yaml index 8c55705e7..9fd6f915b 100644 --- a/.github/workflows/ci-json-validation.yaml +++ b/.github/workflows/ci-json-validation.yaml @@ -1,5 +1,5 @@ # Description: Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq -name: "CI: JSON Validation" +name: 'CI: JSON Validation' on: push: diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 3ce6d6aa9..e001dc234 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -1,5 +1,5 @@ # Description: Linting and code formatting validation for pull requests -name: "CI: Lint Format" +name: 'CI: Lint Format' on: pull_request: @@ -42,7 +42,7 @@ jobs: - name: Run Stylelint with auto-fix run: pnpm stylelint:fix - - name: Run Prettier with auto-format + - name: Run oxfmt with auto-format run: pnpm format - name: Check for changes @@ -60,7 +60,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . - git commit -m "[automated] Apply ESLint and Prettier fixes" + git commit -m "[automated] Apply ESLint and Oxfmt fixes" git push - name: Final validation @@ -80,7 +80,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' + body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' }) - name: Comment on PR about manual fix needed diff --git a/.github/workflows/ci-python-validation.yaml b/.github/workflows/ci-python-validation.yaml index 1625ca29f..b06296391 100644 --- a/.github/workflows/ci-python-validation.yaml +++ b/.github/workflows/ci-python-validation.yaml @@ -1,5 +1,5 @@ # Description: Validates Python code in tools/devtools directory -name: "CI: Python Validation" +name: 'CI: Python Validation' on: pull_request: diff --git a/.github/workflows/ci-shell-validation.yaml b/.github/workflows/ci-shell-validation.yaml index 783d1b03c..72ac4be23 100644 --- a/.github/workflows/ci-shell-validation.yaml +++ b/.github/workflows/ci-shell-validation.yaml @@ -1,5 +1,5 @@ # Description: Runs shellcheck on tracked shell scripts when they change -name: "CI: Shell Validation" +name: 'CI: Shell Validation' on: push: diff --git a/.github/workflows/ci-size-data.yaml b/.github/workflows/ci-size-data.yaml index a21c93110..c88be8ad5 100644 --- a/.github/workflows/ci-size-data.yaml +++ b/.github/workflows/ci-size-data.yaml @@ -1,4 +1,4 @@ -name: "CI: Size Data" +name: 'CI: Size Data' on: push: diff --git a/.github/workflows/ci-tests-storybook-forks.yaml b/.github/workflows/ci-tests-storybook-forks.yaml index e93b5bb90..3012f61f2 100644 --- a/.github/workflows/ci-tests-storybook-forks.yaml +++ b/.github/workflows/ci-tests-storybook-forks.yaml @@ -1,9 +1,9 @@ # Description: Deploys Storybook previews from forked PRs (forks can't access deployment secrets) -name: "CI: Tests Storybook (Deploy for Forks)" +name: 'CI: Tests Storybook (Deploy for Forks)' on: workflow_run: - workflows: ["CI: Tests Storybook"] + workflows: ['CI: Tests Storybook'] types: [requested, completed] env: diff --git a/.github/workflows/ci-tests-storybook.yaml b/.github/workflows/ci-tests-storybook.yaml index 8158787a2..e900a6f0f 100644 --- a/.github/workflows/ci-tests-storybook.yaml +++ b/.github/workflows/ci-tests-storybook.yaml @@ -1,8 +1,8 @@ # Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages -name: "CI: Tests Storybook" +name: 'CI: Tests Storybook' on: - workflow_dispatch: # Allow manual triggering + workflow_dispatch: # Allow manual triggering pull_request: jobs: @@ -88,7 +88,7 @@ jobs: - name: Checkout code uses: actions/checkout@v5 with: - fetch-depth: 0 # Required for Chromatic baseline + fetch-depth: 0 # Required for Chromatic baseline - name: Install pnpm uses: pnpm/action-setup@v4 @@ -110,9 +110,9 @@ jobs: with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} buildScriptName: build-storybook - autoAcceptChanges: 'main' # Auto-accept changes on main branch - exitOnceUploaded: true # Don't wait for UI tests to complete - onlyChanged: true # Only capture changed stories + autoAcceptChanges: 'main' # Auto-accept changes on main branch + exitOnceUploaded: true # Don't wait for UI tests to complete + onlyChanged: true # Only capture changed stories - name: Set job status id: job-status diff --git a/.github/workflows/ci-tests-unit.yaml b/.github/workflows/ci-tests-unit.yaml index 8a97a9128..c2a8c1f15 100644 --- a/.github/workflows/ci-tests-unit.yaml +++ b/.github/workflows/ci-tests-unit.yaml @@ -1,5 +1,5 @@ # Description: Unit and component testing with Vitest -name: "CI: Tests Unit" +name: 'CI: Tests Unit' on: push: @@ -26,8 +26,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: "lts/*" - cache: "pnpm" + node-version: 'lts/*' + cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/ci-yaml-validation.yaml b/.github/workflows/ci-yaml-validation.yaml index cf2a3b648..788c6b188 100644 --- a/.github/workflows/ci-yaml-validation.yaml +++ b/.github/workflows/ci-yaml-validation.yaml @@ -1,5 +1,5 @@ # Description: Validates YAML syntax and style using yamllint with relaxed rules -name: "CI: YAML Validation" +name: 'CI: YAML Validation' on: push: diff --git a/.github/workflows/i18n-update-core.yaml b/.github/workflows/i18n-update-core.yaml index a4e73f538..0ceaf7397 100644 --- a/.github/workflows/i18n-update-core.yaml +++ b/.github/workflows/i18n-update-core.yaml @@ -1,5 +1,5 @@ # Description: Generates and updates translations for core ComfyUI components using OpenAI -name: "i18n: Update Core" +name: 'i18n: Update Core' on: # Manual dispatch for urgent translation updates diff --git a/.github/workflows/i18n-update-nodes.yaml b/.github/workflows/i18n-update-nodes.yaml index 46db1661e..9afc1f195 100644 --- a/.github/workflows/i18n-update-nodes.yaml +++ b/.github/workflows/i18n-update-nodes.yaml @@ -43,8 +43,8 @@ jobs: uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e with: token: ${{ secrets.PR_GH_TOKEN }} - commit-message: "Update locales for node definitions" - title: "Update locales for node definitions" + commit-message: 'Update locales for node definitions' + title: 'Update locales for node definitions' body: | Automated PR to update locales for node definitions diff --git a/.github/workflows/pr-backport.yaml b/.github/workflows/pr-backport.yaml index c38a8081e..968fcfd81 100644 --- a/.github/workflows/pr-backport.yaml +++ b/.github/workflows/pr-backport.yaml @@ -462,7 +462,6 @@ jobs: fi done < "$FILE" - - name: Remove needs-backport label if: steps.filter-targets.outputs.skip != 'true' && success() run: gh pr edit ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} --remove-label "needs-backport" diff --git a/.github/workflows/pr-claude-review.yaml b/.github/workflows/pr-claude-review.yaml index ec4159c1c..56fcc8c9b 100644 --- a/.github/workflows/pr-claude-review.yaml +++ b/.github/workflows/pr-claude-review.yaml @@ -1,5 +1,5 @@ # Description: AI-powered code review triggered by adding the 'claude-review' label to a PR -name: "PR: Claude Review" +name: 'PR: Claude Review' permissions: contents: read @@ -46,7 +46,7 @@ jobs: - name: Run Claude PR Review uses: anthropics/claude-code-action@v1.0.6 with: - label_trigger: "claude-review" + label_trigger: 'claude-review' prompt: | Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly. diff --git a/.github/workflows/pr-size-report.yaml b/.github/workflows/pr-size-report.yaml index 968888aa9..38b742054 100644 --- a/.github/workflows/pr-size-report.yaml +++ b/.github/workflows/pr-size-report.yaml @@ -1,4 +1,4 @@ -name: "PR: Size Report" +name: 'PR: Size Report' on: workflow_run: diff --git a/.github/workflows/pr-update-playwright-expectations.yaml b/.github/workflows/pr-update-playwright-expectations.yaml index 0ca696721..628bc3039 100644 --- a/.github/workflows/pr-update-playwright-expectations.yaml +++ b/.github/workflows/pr-update-playwright-expectations.yaml @@ -1,5 +1,5 @@ # Setting test expectation screenshots for Playwright -name: "PR: Update Playwright Expectations" +name: 'PR: Update Playwright Expectations' on: pull_request: @@ -39,11 +39,11 @@ jobs: - name: Find Update Comment uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad - id: "find-update-comment" + id: 'find-update-comment' with: issue-number: ${{ steps.pr-info.outputs.pr-number }} - comment-author: "github-actions[bot]" - body-includes: "Updating Playwright Expectations" + comment-author: 'github-actions[bot]' + body-includes: 'Updating Playwright Expectations' - name: Add Starting Reaction uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 diff --git a/.github/workflows/release-biweekly-comfyui.yaml b/.github/workflows/release-biweekly-comfyui.yaml index 6eb45a00e..8c75548ce 100644 --- a/.github/workflows/release-biweekly-comfyui.yaml +++ b/.github/workflows/release-biweekly-comfyui.yaml @@ -1,5 +1,5 @@ # Automated bi-weekly workflow to bump ComfyUI frontend RC releases -name: "Release: Bi-weekly ComfyUI" +name: 'Release: Bi-weekly ComfyUI' on: # Schedule for Monday at 12:00 PM PST (20:00 UTC) diff --git a/.github/workflows/release-version-bump.yaml b/.github/workflows/release-version-bump.yaml index ca08e25bd..4f0d033d9 100644 --- a/.github/workflows/release-version-bump.yaml +++ b/.github/workflows/release-version-bump.yaml @@ -1,5 +1,5 @@ # Description: Manual workflow to increment package version with semantic versioning support -name: "Release: Version Bump" +name: 'Release: Version Bump' on: workflow_dispatch: diff --git a/.github/workflows/weekly-docs-check.yaml b/.github/workflows/weekly-docs-check.yaml index 317e4d8fe..d5c8dc51e 100644 --- a/.github/workflows/weekly-docs-check.yaml +++ b/.github/workflows/weekly-docs-check.yaml @@ -1,5 +1,5 @@ # Description: Automated weekly documentation accuracy check and update via Claude -name: "Weekly Documentation Check" +name: 'Weekly Documentation Check' permissions: contents: write diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 86ce06eaa..4369f0a70 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -1,7 +1,7 @@ // This file is intentionally kept in CommonJS format (.cjs) // to resolve compatibility issues with dependencies that require CommonJS. // Do not convert this file to ESModule format unless all dependencies support it. -const { defineConfig } = require('@lobehub/i18n-cli'); +const { defineConfig } = require('@lobehub/i18n-cli') module.exports = defineConfig({ modelName: 'gpt-4.1', @@ -10,7 +10,19 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], + outputLocales: [ + 'zh', + 'zh-TW', + 'ru', + 'ja', + 'ko', + 'fr', + 'es', + 'ar', + 'tr', + 'pt-BR', + 'fa' + ], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. @@ -26,4 +38,4 @@ module.exports = defineConfig({ - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Maintain consistency with terminology used in Persian software and design applications. ` -}); +}) diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..1278864b2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,14 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "tabWidth": 2, + "semi": false, + "trailingComma": "none", + "printWidth": 80, + "ignorePatterns": [ + "packages/registry-types/src/comfyRegistryTypes.ts", + "public/materialdesignicons.min.css", + "src/types/generatedManagerTypes.ts", + "**/__fixtures__/**/*.json" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json index 276ef5461..a0769d8f4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -31,10 +31,7 @@ "no-console": [ "error", { - "allow": [ - "warn", - "error" - ] + "allow": ["warn", "error"] } ], "no-control-regex": "off", @@ -66,12 +63,7 @@ }, { "name": "@/i18n--to-enable", - "importNames": [ - "st", - "t", - "te", - "d" - ], + "importNames": ["st", "t", "te", "d"], "message": "Don't import `@/i18n` directly, prefer `useI18n()`" } ] @@ -85,10 +77,7 @@ "import/export": "error", "import/namespace": "error", "import/no-duplicates": "error", - "import/consistent-type-specifier-style": [ - "error", - "prefer-top-level" - ], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], "jest/expect-expect": "off", "jest/no-conditional-expect": "off", "jest/no-disabled-tests": "off", @@ -117,13 +106,10 @@ }, "overrides": [ { - "files": [ - "**/*.{stories,test,spec}.ts", - "**/*.stories.vue" - ], + "files": ["**/*.{stories,test,spec}.ts", "**/*.stories.vue"], "rules": { "no-console": "allow" } } ] -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4403edd8e..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/registry-types/src/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa43a43ac..000000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "semi": false, - "trailingComma": "none", - "printWidth": 80, - "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"] -} diff --git a/.storybook/AGENTS.md b/.storybook/AGENTS.md new file mode 100644 index 000000000..aff114239 --- /dev/null +++ b/.storybook/AGENTS.md @@ -0,0 +1,18 @@ +# Storybook Guidelines + +See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`). + +## Available Context + +Stories have access to: + +- All ComfyUI stores +- PrimeVue with ComfyUI theming +- i18n system +- CSS variables and styling + +## Troubleshooting + +1. **Import Errors**: Verify `@/` alias works +2. **Missing Styles**: Check CSS imports in `preview.ts` +3. **Store Errors**: Check store initialization in setup diff --git a/.storybook/CLAUDE.md b/.storybook/CLAUDE.md index ca8248784..4837fdae9 100644 --- a/.storybook/CLAUDE.md +++ b/.storybook/CLAUDE.md @@ -1,197 +1,4 @@ -# Storybook Development Guidelines for Claude + -## Quick Commands - -- `pnpm storybook`: Start Storybook development server -- `pnpm build-storybook`: Build static Storybook -- `pnpm test:unit`: Run unit tests (includes Storybook components) - -## Development Workflow for Storybook - -1. **Creating New Stories**: - - Place `*.stories.ts` files alongside components - - Follow the naming pattern: `ComponentName.stories.ts` - - Use realistic mock data that matches ComfyUI schemas - -2. **Testing Stories**: - - Verify stories render correctly in Storybook UI - - Test different component states and edge cases - - Ensure proper theming and styling - -3. **Code Quality**: - - Run `pnpm typecheck` to verify TypeScript - - Run `pnpm lint` to check for linting issues - - Follow existing story patterns and conventions - -## Story Creation Guidelines - -### Basic Story Structure - -```typescript -import type { Meta, StoryObj } from '@storybook/vue3' -import ComponentName from './ComponentName.vue' - -const meta: Meta = { - title: 'Category/ComponentName', - component: ComponentName, - parameters: { - layout: 'centered' // or 'fullscreen', 'padded' - } -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - // Component props - } -} -``` - -### Mock Data Patterns - -For ComfyUI components, use realistic mock data: - -```typescript -// Node definition mock -const mockNodeDef = { - input: { - required: { - prompt: ["STRING", { multiline: true }] - } - }, - output: ["CONDITIONING"], - output_is_list: [false], - category: "conditioning" -} - -// Component instance mock -const mockComponent = { - id: "1", - type: "CLIPTextEncode", - // ... other properties -} -``` - -### Common Story Variants - -Always include these story variants when applicable: - -- **Default**: Basic component with minimal props -- **WithData**: Component with realistic data -- **Loading**: Component in loading state -- **Error**: Component with error state -- **LongContent**: Component with edge case content -- **Empty**: Component with no data - -### Storybook-Specific Code Patterns - -#### Store Access -```typescript -// In stories, access stores through the setup function -export const WithStore: Story = { - render: () => ({ - setup() { - const store = useMyStore() - return { store } - }, - template: '' - }) -} -``` - -#### Event Testing -```typescript -export const WithEvents: Story = { - args: { - onUpdate: fn() // Use Storybook's fn() for action logging - } -} -``` - -## Configuration Notes - -### Vue App Setup -The Storybook preview is configured with: -- Pinia stores initialized -- PrimeVue with ComfyUI theme -- i18n internationalization -- All necessary CSS imports - -### Build Configuration -- Vite integration with proper alias resolution -- Manual chunking for better performance -- TypeScript support with strict checking -- CSS processing for Vue components - -## Troubleshooting - -### Common Issues - -1. **Import Errors**: Verify `@/` alias is working correctly -2. **Missing Styles**: Ensure CSS imports are in `preview.ts` -3. **Store Errors**: Check store initialization in setup -4. **Type Errors**: Use proper TypeScript types for story args - -### Debug Commands - -```bash -# Check TypeScript issues -pnpm typecheck - -# Lint Storybook files -pnpm lint .storybook/ - -# Build to check for production issues -pnpm build-storybook -``` - -## File Organization - -``` -.storybook/ -├── main.ts # Core configuration -├── preview.ts # Global setup and decorators -├── README.md # User documentation -└── CLAUDE.md # This file - Claude guidelines - -src/ -├── components/ -│ └── MyComponent/ -│ ├── MyComponent.vue -│ └── MyComponent.stories.ts -``` - -## Integration with ComfyUI - -### Available Context - -Stories have access to: -- All ComfyUI stores (widgetStore, colorPaletteStore, etc.) -- PrimeVue components with ComfyUI theming -- Internationalization system -- ComfyUI CSS variables and styling - -### Testing Components - -When testing ComfyUI-specific components: -1. Use realistic node definitions and data structures -2. Test with different node types (sampling, conditioning, etc.) -3. Verify proper CSS theming and dark/light modes -4. Check component behavior with various input combinations - -### Performance Considerations - -- Use manual chunking for large dependencies -- Minimize bundle size by avoiding unnecessary imports -- Leverage Storybook's lazy loading capabilities -- Profile build times and optimize as needed - -## Best Practices - -1. **Keep Stories Focused**: Each story should demonstrate one specific use case -2. **Use Descriptive Names**: Story names should clearly indicate what they show -3. **Document Complex Props**: Use JSDoc comments for complex prop types -4. **Test Edge Cases**: Create stories for unusual but valid use cases -5. **Maintain Consistency**: Follow established patterns in existing stories \ No newline at end of file +@AGENTS.md diff --git a/.storybook/README.md b/.storybook/README.md index be5405e51..ba09b24d5 100644 --- a/.storybook/README.md +++ b/.storybook/README.md @@ -12,16 +12,17 @@ Storybook is a frontend workshop for building UI components and pages in isolati ## Storybook vs Other Testing Tools -| Tool | Purpose | Use Case | -|------|---------|----------| -| **Storybook** | Component isolation & documentation | Developing, testing, and showcasing individual UI components | -| **Playwright** | End-to-end testing | Full user workflow testing across multiple pages | -| **Vitest** | Unit testing | Testing business logic, utilities, and component behavior | -| **Vue Testing Library** | Component testing | Testing component interactions and DOM output | +| Tool | Purpose | Use Case | +| ----------------------- | ----------------------------------- | ------------------------------------------------------------ | +| **Storybook** | Component isolation & documentation | Developing, testing, and showcasing individual UI components | +| **Playwright** | End-to-end testing | Full user workflow testing across multiple pages | +| **Vitest** | Unit testing | Testing business logic, utilities, and component behavior | +| **Vue Testing Library** | Component testing | Testing component interactions and DOM output | ### When to Use Storybook **✅ Use Storybook for:** + - Developing new UI components in isolation - Creating component documentation and examples - Testing different component states and props @@ -30,6 +31,7 @@ Storybook is a frontend workshop for building UI components and pages in isolati - Building a component library or design system **❌ Don't use Storybook for:** + - Testing complex user workflows (use Playwright) - Testing business logic (use Vitest) - Integration testing between components (use Vue Testing Library) @@ -96,6 +98,7 @@ export const WithVariant: Story = { ## ComfyUI Storybook Guidelines ### Scope – When to Create Stories + - **PrimeVue components**: No need to create stories. Just refer to the official PrimeVue documentation. - **Custom shared components (design system components)**: @@ -104,6 +107,7 @@ export const WithVariant: Story = { Do not create stories. Only the underlying pure UI components should be included in Storybook. ### Maintenance Philosophy + - Stories are lightweight and generally stable. Once created, they rarely need updates unless: - The design changes @@ -111,10 +115,12 @@ export const WithVariant: Story = { - For existing usage patterns, simply copy real code examples into Storybook to create stories. ### File Placement -- Keep `*.stories.ts` files at the **same level as the component** (similar to test files). + +- Keep `*.stories.ts` files at the **same level as the component** (similar to test files). - This makes it easier to check usage examples without navigating to another directory. ### Developer/Designer Workflow + - **UI vs Container**: Separate pure UI components from container components. Only UI components should live in Storybook. - **Communication Tool**: Storybook is not just about code quality—it enables designers and developers to see: @@ -126,9 +132,10 @@ export const WithVariant: Story = { → Only create a story for the base UI button, not for the wrapper. ### Suggested Workflow -1. Use PrimeVue docs for standard components -2. Use Storybook for **shared/custom components** that define our design system -3. Keep story files alongside components + +1. Use PrimeVue docs for standard components +2. Use Storybook for **shared/custom components** that define our design system +3. Keep story files alongside components 4. When in doubt, focus on components reused across the app or those that need to be showcased to designers ### Best Practices @@ -211,13 +218,12 @@ This Storybook setup includes: ## Icon Usage in Storybook -In this project, only the `` syntax from unplugin-icons is supported in Storybook. +In this project, only the `` syntax from unplugin-icons is supported in Storybook. **Example:** ```vue - + @@ -302,7 +315,7 @@ const { width, height, top, left } = useElementBounding(elementRef) import { useAsyncState } from '@vueuse/core' const { state, isReady, isLoading } = useAsyncState( - fetch('https://api.example.com/data').then(r => r.json()), + fetch('https://api.example.com/data').then((r) => r.json()), { data: [] } ) ``` @@ -327,7 +340,7 @@ When creating or modifying composables, follow these best practices: Here's a template for creating a new composable: ```typescript -import { ref, computed, onMounted, onUnmounted } from 'vue'; +import { ref, computed, onMounted, onUnmounted } from 'vue' /** * Composable for [functionality description] @@ -338,13 +351,13 @@ export function useExample(options = {}) { // State const state = ref({ // Initial state - }); + }) // Computed values const derivedValue = computed(() => { // Compute from state - return state.value.someProperty; - }); + return state.value.someProperty + }) // Methods function doSomething() { @@ -354,18 +367,18 @@ export function useExample(options = {}) { // Lifecycle hooks onMounted(() => { // Setup - }); + }) onUnmounted(() => { // Cleanup - }); + }) // Return exposed state and methods return { state, derivedValue, doSomething - }; + } } ``` @@ -377,60 +390,65 @@ Composables in ComfyUI frequently use these patterns: ```typescript export function useState() { - const count = ref(0); - + const count = ref(0) + function increment() { - count.value++; + count.value++ } - + return { count, increment - }; + } } ``` ### Event Handling with VueUse ```typescript -import { useEventListener } from '@vueuse/core'; +import { useEventListener } from '@vueuse/core' export function useKeyPress(key) { - const isPressed = ref(false); - + const isPressed = ref(false) + useEventListener('keydown', (e) => { if (e.key === key) { - isPressed.value = true; + isPressed.value = true } - }); - + }) + useEventListener('keyup', (e) => { if (e.key === key) { - isPressed.value = false; + isPressed.value = false } - }); - - return { isPressed }; + }) + + return { isPressed } } ``` ### Fetch & Load with VueUse ```typescript -import { useAsyncState } from '@vueuse/core'; +import { useAsyncState } from '@vueuse/core' export function useFetchData(url) { - const { state: data, isLoading, error, execute: refresh } = useAsyncState( + const { + state: data, + isLoading, + error, + execute: refresh + } = useAsyncState( async () => { - const response = await fetch(url); - if (!response.ok) throw new Error('Failed to fetch data'); - return response.json(); + const response = await fetch(url) + if (!response.ok) throw new Error('Failed to fetch data') + return response.json() }, null, { immediate: true } - ); - - return { data, isLoading, error, refresh }; + ) + + return { data, isLoading, error, refresh } } ``` diff --git a/src/composables/canvas/useSelectedLiteGraphItems.test.ts b/src/composables/canvas/useSelectedLiteGraphItems.test.ts index 23e1e8dd3..8e16448f4 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.test.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -6,6 +6,9 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph' import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' +import type { NodeId } from '@/renderer/core/layout/types' +import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' +import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils' // Mock the app module vi.mock('@/scripts/app', () => ({ @@ -29,10 +32,12 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({ })) // Mock Positionable objects -// @ts-expect-error - Mock implementation for testing + class MockNode implements Positionable { pos: [number, number] size: [number, number] + id: NodeId + boundingRect: ReadOnlyRect constructor( pos: [number, number] = [0, 0], @@ -40,6 +45,13 @@ class MockNode implements Positionable { ) { this.pos = pos this.size = size + this.id = 'mock-node' + this.boundingRect = [0, 0, 0, 0] + } + + move(): void {} + snapToGrid(_: number): boolean { + return true } } @@ -61,7 +73,7 @@ class MockReroute extends Reroute implements Positionable { describe('useSelectedLiteGraphItems', () => { let canvasStore: ReturnType - let mockCanvas: any + let mockCanvas: { selectedItems: Set } beforeEach(() => { setActivePinia(createPinia()) @@ -73,7 +85,9 @@ describe('useSelectedLiteGraphItems', () => { } // Mock getCanvas to return our mock canvas - vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas) + vi.spyOn(canvasStore, 'getCanvas').mockReturnValue( + mockCanvas as ReturnType + ) }) describe('isIgnoredItem', () => { @@ -86,7 +100,6 @@ describe('useSelectedLiteGraphItems', () => { it('should return false for non-Reroute items', () => { const { isIgnoredItem } = useSelectedLiteGraphItems() const node = new MockNode() - // @ts-expect-error - Test mock expect(isIgnoredItem(node)).toBe(false) }) }) @@ -98,14 +111,11 @@ describe('useSelectedLiteGraphItems', () => { const node2 = new MockNode([100, 100]) const reroute = new MockReroute([50, 50]) - // @ts-expect-error - Test mocks const items = new Set([node1, node2, reroute]) const filtered = filterSelectableItems(items) expect(filtered.size).toBe(2) - // @ts-expect-error - Test mocks expect(filtered.has(node1)).toBe(true) - // @ts-expect-error - Test mocks expect(filtered.has(node2)).toBe(true) expect(filtered.has(reroute)).toBe(false) }) @@ -143,9 +153,7 @@ describe('useSelectedLiteGraphItems', () => { const selectableItems = getSelectableItems() expect(selectableItems.size).toBe(2) - // @ts-expect-error - Test mock expect(selectableItems.has(node1)).toBe(true) - // @ts-expect-error - Test mock expect(selectableItems.has(node2)).toBe(true) expect(selectableItems.has(reroute)).toBe(false) }) @@ -255,14 +263,7 @@ describe('useSelectedLiteGraphItems', () => { const { getSelectedNodes } = useSelectedLiteGraphItems() const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode - const subgraphNode = { - id: 1, - mode: LGraphEventMode.ALWAYS, - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode + const subgraphNode = createMockSubgraphNode([subNode1, subNode2]) const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode } @@ -279,14 +280,7 @@ describe('useSelectedLiteGraphItems', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode - const subgraphNode = { - id: 1, - mode: LGraphEventMode.ALWAYS, - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode + const subgraphNode = createMockSubgraphNode([subNode1, subNode2]) const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode } @@ -310,14 +304,10 @@ describe('useSelectedLiteGraphItems', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode - const subgraphNode = { + const subgraphNode = createMockSubgraphNode([subNode1, subNode2], { id: 1, - mode: LGraphEventMode.NEVER, // Already in NEVER mode - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode + mode: LGraphEventMode.NEVER // Already in NEVER mode + }) app.canvas.selected_nodes = { '0': subgraphNode } diff --git a/src/composables/element/useResponsiveCollapse.ts b/src/composables/element/useResponsiveCollapse.ts deleted file mode 100644 index 4f1846865..000000000 --- a/src/composables/element/useResponsiveCollapse.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { breakpointsTailwind, useBreakpoints } from '@vueuse/core' -import { ref, watch } from 'vue' - -type BreakpointKey = keyof typeof breakpointsTailwind - -/** - * Composable for element with responsive collapsed state - * @param breakpointThreshold - Breakpoint at which the element should become collapsible - */ -export const useResponsiveCollapse = ( - breakpointThreshold: BreakpointKey = 'lg' -) => { - const breakpoints = useBreakpoints(breakpointsTailwind) - - const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold) - const isOpen = ref(!isSmallScreen.value) - - /** - * Handles screen size changes to automatically open/close the element - * when crossing the breakpoint threshold - */ - const onIsSmallScreenChange = () => { - if (isSmallScreen.value && isOpen.value) { - isOpen.value = false - } else if (!isSmallScreen.value && !isOpen.value) { - isOpen.value = true - } - } - - watch(isSmallScreen, onIsSmallScreenChange) - - return { - breakpoints, - isOpen, - isSmallScreen, - - open: () => (isOpen.value = true), - close: () => (isOpen.value = false), - toggle: () => (isOpen.value = !isOpen.value) - } -} diff --git a/src/composables/functional/useChainCallback.ts b/src/composables/functional/useChainCallback.ts index 47e32b9fb..833cd4a18 100644 --- a/src/composables/functional/useChainCallback.ts +++ b/src/composables/functional/useChainCallback.ts @@ -1,19 +1,3 @@ -/** - * Shorthand for {@link Parameters} of optional callbacks. - * - * @example - * ```ts - * const { onClick } = CustomClass.prototype - * CustomClass.prototype.onClick = function (...args: CallbackParams) { - * const r = onClick?.apply(this, args) - * // ... - * return r - * } - * ``` - */ -export type CallbackParams any) | undefined> = - Parameters> - /** * Chain multiple callbacks together. * @@ -21,15 +5,21 @@ export type CallbackParams any) | undefined> = * @param callbacks - The callbacks to chain. * @returns A new callback that chains the original callback with the callbacks. */ -export const useChainCallback = < - O, - T extends (this: O, ...args: any[]) => void ->( +export function useChainCallback( originalCallback: T | undefined, - ...callbacks: ((this: O, ...args: Parameters) => void)[] -) => { - return function (this: O, ...args: Parameters) { - originalCallback?.call(this, ...args) - for (const callback of callbacks) callback.call(this, ...args) - } + ...callbacks: NonNullable extends (this: O, ...args: infer P) => unknown + ? ((this: O, ...args: P) => void)[] + : never +) { + type Args = NonNullable extends (...args: infer P) => unknown ? P : never + type Ret = NonNullable extends (...args: unknown[]) => infer R ? R : never + + return function (this: O, ...args: Args) { + if (typeof originalCallback === 'function') { + ;(originalCallback as (this: O, ...args: Args) => Ret).call(this, ...args) + } + for (const callback of callbacks) { + callback.call(this, ...args) + } + } as (this: O, ...args: Args) => Ret } diff --git a/src/composables/graph/useGraphHierarchy.test.ts b/src/composables/graph/useGraphHierarchy.test.ts index 510d50989..c7b5a8f81 100644 --- a/src/composables/graph/useGraphHierarchy.test.ts +++ b/src/composables/graph/useGraphHierarchy.test.ts @@ -1,23 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph' import * as measure from '@/lib/litegraph/src/measure' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { + createMockLGraphNode, + createMockLGraphGroup +} from '@/utils/__tests__/litegraphTestUtils' import { useGraphHierarchy } from './useGraphHierarchy' vi.mock('@/renderer/core/canvas/canvasStore') +function createMockNode(overrides: Partial = {}): LGraphNode { + return Object.assign( + createMockLGraphNode(), + { + boundingRect: new Rectangle(100, 100, 50, 50) + }, + overrides + ) +} + +function createMockGroup(overrides: Partial = {}): LGraphGroup { + return createMockLGraphGroup(overrides) +} + describe('useGraphHierarchy', () => { - let mockCanvasStore: ReturnType + let mockCanvasStore: Partial> let mockNode: LGraphNode let mockGroups: LGraphGroup[] beforeEach(() => { - mockNode = { - boundingRect: [100, 100, 50, 50] - } as unknown as LGraphNode - + mockNode = createMockNode() mockGroups = [] mockCanvasStore = { @@ -25,10 +41,21 @@ describe('useGraphHierarchy', () => { graph: { groups: mockGroups } - } - } as any + }, + $id: 'canvas', + $state: {}, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set(), + _p: {} + } as unknown as Partial> - vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore) + vi.mocked(useCanvasStore).mockReturnValue( + mockCanvasStore as ReturnType + ) }) describe('findParentGroup', () => { @@ -41,9 +68,9 @@ describe('useGraphHierarchy', () => { }) it('returns null when node is not in any group', () => { - const group = { - boundingRect: [0, 0, 50, 50] - } as unknown as LGraphGroup + const group = createMockGroup({ + boundingRect: new Rectangle(0, 0, 50, 50) + }) mockGroups.push(group) vi.spyOn(measure, 'containsCentre').mockReturnValue(false) @@ -55,9 +82,9 @@ describe('useGraphHierarchy', () => { }) it('returns the only group when node is in exactly one group', () => { - const group = { - boundingRect: [0, 0, 200, 200] - } as unknown as LGraphGroup + const group = createMockGroup({ + boundingRect: new Rectangle(0, 0, 200, 200) + }) mockGroups.push(group) vi.spyOn(measure, 'containsCentre').mockReturnValue(true) @@ -69,12 +96,12 @@ describe('useGraphHierarchy', () => { }) it('returns the smallest group when node is in multiple groups', () => { - const largeGroup = { - boundingRect: [0, 0, 300, 300] - } as unknown as LGraphGroup - const smallGroup = { - boundingRect: [50, 50, 100, 100] - } as unknown as LGraphGroup + const largeGroup = createMockGroup({ + boundingRect: new Rectangle(0, 0, 300, 300) + }) + const smallGroup = createMockGroup({ + boundingRect: new Rectangle(50, 50, 100, 100) + }) mockGroups.push(largeGroup, smallGroup) vi.spyOn(measure, 'containsCentre').mockReturnValue(true) @@ -87,12 +114,12 @@ describe('useGraphHierarchy', () => { }) it('returns the inner group when one group contains another', () => { - const outerGroup = { - boundingRect: [0, 0, 300, 300] - } as unknown as LGraphGroup - const innerGroup = { - boundingRect: [50, 50, 100, 100] - } as unknown as LGraphGroup + const outerGroup = createMockGroup({ + boundingRect: new Rectangle(0, 0, 300, 300) + }) + const innerGroup = createMockGroup({ + boundingRect: new Rectangle(50, 50, 100, 100) + }) mockGroups.push(outerGroup, innerGroup) vi.spyOn(measure, 'containsCentre').mockReturnValue(true) @@ -113,7 +140,7 @@ describe('useGraphHierarchy', () => { }) it('handles null canvas gracefully', () => { - mockCanvasStore.canvas = null as any + mockCanvasStore.canvas = null const { findParentGroup } = useGraphHierarchy() const result = findParentGroup(mockNode) @@ -122,7 +149,7 @@ describe('useGraphHierarchy', () => { }) it('handles null graph gracefully', () => { - mockCanvasStore.canvas!.graph = null as any + mockCanvasStore.canvas!.graph = null const { findParentGroup } = useGraphHierarchy() const result = findParentGroup(mockNode) diff --git a/tests-ui/tests/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts similarity index 100% rename from tests-ui/tests/composables/graph/useGraphNodeManager.test.ts rename to src/composables/graph/useGraphNodeManager.test.ts diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index e71e4cf36..33822d1c5 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -77,6 +77,7 @@ export interface VueNodeData { outputs?: INodeOutputSlot[] resizable?: boolean shape?: number + showAdvanced?: boolean subgraphId?: string | null titleMode?: TitleMode widgets?: SafeWidgetData[] @@ -314,7 +315,8 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData { color: node.color || undefined, bgcolor: node.bgcolor || undefined, resizable: node.resizable, - shape: node.shape + shape: node.shape, + showAdvanced: node.showAdvanced } } @@ -398,6 +400,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { vueNodeData.set(id, extractVueNodeData(node)) const initializeVueNodeLayout = () => { + // Check if the node was removed mid-sequence + if (!nodeRefs.has(id)) return + // Extract actual positions after configure() has potentially updated them const nodePosition = { x: node.pos[0], y: node.pos[1] } const nodeSize = { width: node.size[0], height: node.size[1] } @@ -427,7 +432,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { } else { // Not during workflow loading - initialize layout immediately // This handles individual node additions during normal operation - initializeVueNodeLayout() + requestAnimationFrame(initializeVueNodeLayout) } // Call original callback if provided @@ -565,6 +570,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ? propertyEvent.newValue : undefined }) + break + case 'showAdvanced': + vueNodeData.set(nodeId, { + ...currentData, + showAdvanced: Boolean(propertyEvent.newValue) + }) + break } } }, diff --git a/src/composables/graph/useSelectionState.test.ts b/src/composables/graph/useSelectionState.test.ts index cf0e4bd84..cd4af9422 100644 --- a/src/composables/graph/useSelectionState.test.ts +++ b/src/composables/graph/useSelectionState.test.ts @@ -1,55 +1,19 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { ref } from 'vue' -import type { Ref } from 'vue' import { useSelectionState } from '@/composables/graph/useSelectionState' import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { useNodeDefStore } from '@/stores/nodeDefStore' -import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' -import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil' import { filterOutputNodes } from '@/utils/nodeFilterUtil' +import { + createMockLGraphNode, + createMockPositionable +} from '@/utils/__tests__/litegraphTestUtils' -// Test interfaces -interface TestNodeConfig { - type?: string - mode?: LGraphEventMode - flags?: { collapsed?: boolean } - pinned?: boolean - removable?: boolean -} - -interface TestNode { - type: string - mode: LGraphEventMode - flags?: { collapsed?: boolean } - pinned?: boolean - removable?: boolean - isSubgraphNode: () => boolean -} - -type MockedItem = TestNode | { type: string; isNode: boolean } - -// Mock all stores -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: vi.fn() -})) - -vi.mock('@/stores/nodeDefStore', () => ({ - useNodeDefStore: vi.fn() -})) - -vi.mock('@/stores/workspace/sidebarTabStore', () => ({ - useSidebarTabStore: vi.fn() -})) - -vi.mock('@/stores/workspace/nodeHelpStore', () => ({ - useNodeHelpStore: vi.fn() -})) - +// Mock composables vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ useNodeLibrarySidebarTab: vi.fn() })) @@ -63,102 +27,28 @@ vi.mock('@/utils/nodeFilterUtil', () => ({ filterOutputNodes: vi.fn() })) -const createTestNode = (config: TestNodeConfig = {}): TestNode => { - return { - type: config.type || 'TestNode', - mode: config.mode || LGraphEventMode.ALWAYS, - flags: config.flags, - pinned: config.pinned, - removable: config.removable, - isSubgraphNode: () => false - } +// Mock comment/connection objects with additional properties +const mockComment = { + ...createMockPositionable({ id: 999 }), + type: 'comment', + isNode: false +} +const mockConnection = { + ...createMockPositionable({ id: 1000 }), + type: 'connection', + isNode: false } -// Mock comment/connection objects -const mockComment = { type: 'comment', isNode: false } -const mockConnection = { type: 'connection', isNode: false } - describe('useSelectionState', () => { - // Mock store instances - let mockSelectedItems: Ref - beforeEach(() => { vi.clearAllMocks() - setActivePinia(createPinia()) - // Setup mock canvas store with proper ref - mockSelectedItems = ref([]) - vi.mocked(useCanvasStore).mockReturnValue({ - selectedItems: mockSelectedItems, - // Add minimal required properties for the store - $id: 'canvas', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) - - // Setup mock node def store - vi.mocked(useNodeDefStore).mockReturnValue({ - fromLGraphNode: vi.fn((node: TestNode) => { - if (node?.type === 'TestNode') { - return { nodePath: 'test.TestNode', name: 'TestNode' } - } - return null - }), - // Add minimal required properties for the store - $id: 'nodeDef', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) - - // Setup mock sidebar tab store - const mockToggleSidebarTab = vi.fn() - vi.mocked(useSidebarTabStore).mockReturnValue({ - activeSidebarTabId: null, - toggleSidebarTab: mockToggleSidebarTab, - // Add minimal required properties for the store - $id: 'sidebarTab', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) - - // Setup mock node help store - const mockOpenHelp = vi.fn() - const mockCloseHelp = vi.fn() - const mockNodeHelpStore = { - isHelpOpen: false, - currentHelpNode: null, - openHelp: mockOpenHelp, - closeHelp: mockCloseHelp, - // Add minimal required properties for the store - $id: 'nodeHelp', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } - vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any) + // Create testing Pinia instance + setActivePinia( + createTestingPinia({ + createSpy: vi.fn + }) + ) // Setup mock composables vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({ @@ -166,7 +56,7 @@ describe('useSelectionState', () => { title: 'Node Library', type: 'custom', render: () => null - } as any) + } as ReturnType) // Setup mock utility functions vi.mocked(isLGraphNode).mockImplementation((item: unknown) => { @@ -177,8 +67,8 @@ describe('useSelectionState', () => { const typedNode = node as { type?: string } return typedNode?.type === 'ImageNode' }) - vi.mocked(filterOutputNodes).mockImplementation( - (nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any + vi.mocked(filterOutputNodes).mockImplementation((nodes) => + nodes.filter((n) => n.type === 'OutputNode') ) }) @@ -189,10 +79,10 @@ describe('useSelectionState', () => { }) test('should return true when items selected', () => { - // Update the mock data before creating the composable - const node1 = createTestNode() - const node2 = createTestNode() - mockSelectedItems.value = [node1, node2] + const canvasStore = useCanvasStore() + const node1 = createMockLGraphNode({ id: 1 }) + const node2 = createMockLGraphNode({ id: 2 }) + canvasStore.$state.selectedItems = [node1, node2] const { hasAnySelection } = useSelectionState() expect(hasAnySelection.value).toBe(true) @@ -201,9 +91,13 @@ describe('useSelectionState', () => { describe('Node Type Filtering', () => { test('should pick only LGraphNodes from mixed selections', () => { - // Update the mock data before creating the composable - const graphNode = createTestNode() - mockSelectedItems.value = [graphNode, mockComment, mockConnection] + const canvasStore = useCanvasStore() + const graphNode = createMockLGraphNode({ id: 3 }) + canvasStore.$state.selectedItems = [ + graphNode, + mockComment, + mockConnection + ] const { selectedNodes } = useSelectionState() expect(selectedNodes.value).toHaveLength(1) @@ -213,9 +107,12 @@ describe('useSelectionState', () => { describe('Node State Computation', () => { test('should detect bypassed nodes', () => { - // Update the mock data before creating the composable - const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS }) - mockSelectedItems.value = [bypassedNode] + const canvasStore = useCanvasStore() + const bypassedNode = createMockLGraphNode({ + id: 4, + mode: LGraphEventMode.BYPASS + }) + canvasStore.$state.selectedItems = [bypassedNode] const { selectedNodes } = useSelectionState() const isBypassed = selectedNodes.value.some( @@ -225,10 +122,13 @@ describe('useSelectionState', () => { }) test('should detect pinned/collapsed states', () => { - // Update the mock data before creating the composable - const pinnedNode = createTestNode({ pinned: true }) - const collapsedNode = createTestNode({ flags: { collapsed: true } }) - mockSelectedItems.value = [pinnedNode, collapsedNode] + const canvasStore = useCanvasStore() + const pinnedNode = createMockLGraphNode({ id: 5, pinned: true }) + const collapsedNode = createMockLGraphNode({ + id: 6, + flags: { collapsed: true } + }) + canvasStore.$state.selectedItems = [pinnedNode, collapsedNode] const { selectedNodes } = useSelectionState() const isPinned = selectedNodes.value.some((n) => n.pinned === true) @@ -244,9 +144,9 @@ describe('useSelectionState', () => { }) test('should provide non-reactive state computation', () => { - // Update the mock data before creating the composable - const node = createTestNode({ pinned: true }) - mockSelectedItems.value = [node] + const canvasStore = useCanvasStore() + const node = createMockLGraphNode({ id: 7, pinned: true }) + canvasStore.$state.selectedItems = [node] const { selectedNodes } = useSelectionState() const isPinned = selectedNodes.value.some((n) => n.pinned === true) @@ -262,7 +162,7 @@ describe('useSelectionState', () => { expect(isBypassed).toBe(false) // Test with empty selection using new composable instance - mockSelectedItems.value = [] + canvasStore.$state.selectedItems = [] const { selectedNodes: newSelectedNodes } = useSelectionState() const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true) expect(newIsPinned).toBe(false) diff --git a/src/composables/maskeditor/useCanvasHistory.test.ts b/src/composables/maskeditor/useCanvasHistory.test.ts index 40985c715..2e96eda00 100644 --- a/src/composables/maskeditor/useCanvasHistory.test.ts +++ b/src/composables/maskeditor/useCanvasHistory.test.ts @@ -75,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') { this.height = height } close() {} - } as unknown as typeof globalThis.ImageBitmap + } as typeof ImageBitmap } describe('useCanvasHistory', () => { diff --git a/src/composables/maskeditor/useCanvasManager.test.ts b/src/composables/maskeditor/useCanvasManager.test.ts index 4fe40df6e..48e1bf7b4 100644 --- a/src/composables/maskeditor/useCanvasManager.test.ts +++ b/src/composables/maskeditor/useCanvasManager.test.ts @@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MaskBlendMode } from '@/extensions/core/maskeditor/types' import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager' const mockStore = { - imgCanvas: null as any, - maskCanvas: null as any, - rgbCanvas: null as any, - imgCtx: null as any, - maskCtx: null as any, - rgbCtx: null as any, - canvasBackground: null as any, + imgCanvas: null! as HTMLCanvasElement, + maskCanvas: null! as HTMLCanvasElement, + rgbCanvas: null! as HTMLCanvasElement, + imgCtx: null! as CanvasRenderingContext2D, + maskCtx: null! as CanvasRenderingContext2D, + rgbCtx: null! as CanvasRenderingContext2D, + canvasBackground: null! as HTMLElement, maskColor: { r: 0, g: 0, b: 0 }, maskBlendMode: MaskBlendMode.Black, maskOpacity: 0.8 @@ -38,26 +38,30 @@ describe('useCanvasManager', () => { height: 100 } as ImageData - mockStore.imgCtx = { + const partialImgCtx: Partial = { drawImage: vi.fn() } + mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D - mockStore.maskCtx = { + const partialMaskCtx: Partial = { drawImage: vi.fn(), getImageData: vi.fn(() => mockImageData), putImageData: vi.fn(), globalCompositeOperation: 'source-over', fillStyle: '' } + mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D - mockStore.rgbCtx = { + const partialRgbCtx: Partial = { drawImage: vi.fn() } + mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D - mockStore.imgCanvas = { + const partialImgCanvas: Partial = { width: 0, height: 0 } + mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement mockStore.maskCanvas = { width: 0, @@ -65,19 +69,19 @@ describe('useCanvasManager', () => { style: { mixBlendMode: '', opacity: '' - } - } + } as Pick + } as HTMLCanvasElement mockStore.rgbCanvas = { width: 0, height: 0 - } + } as HTMLCanvasElement mockStore.canvasBackground = { style: { backgroundColor: '' - } - } + } as Pick + } as HTMLElement mockStore.maskColor = { r: 0, g: 0, b: 0 } mockStore.maskBlendMode = MaskBlendMode.Black @@ -163,7 +167,7 @@ describe('useCanvasManager', () => { it('should throw error when canvas missing', async () => { const manager = useCanvasManager() - mockStore.imgCanvas = null + mockStore.imgCanvas = null! as HTMLCanvasElement const origImage = createMockImage(512, 512) const maskImage = createMockImage(512, 512) @@ -176,7 +180,7 @@ describe('useCanvasManager', () => { it('should throw error when context missing', async () => { const manager = useCanvasManager() - mockStore.imgCtx = null + mockStore.imgCtx = null! as CanvasRenderingContext2D const origImage = createMockImage(512, 512) const maskImage = createMockImage(512, 512) @@ -259,7 +263,7 @@ describe('useCanvasManager', () => { it('should return early when canvas missing', async () => { const manager = useCanvasManager() - mockStore.maskCanvas = null + mockStore.maskCanvas = null! as HTMLCanvasElement await manager.updateMaskColor() @@ -269,7 +273,7 @@ describe('useCanvasManager', () => { it('should return early when context missing', async () => { const manager = useCanvasManager() - mockStore.maskCtx = null + mockStore.maskCtx = null! as CanvasRenderingContext2D await manager.updateMaskColor() diff --git a/src/composables/maskeditor/useCanvasTools.test.ts b/src/composables/maskeditor/useCanvasTools.test.ts index 991d19c00..17a46f8b6 100644 --- a/src/composables/maskeditor/useCanvasTools.test.ts +++ b/src/composables/maskeditor/useCanvasTools.test.ts @@ -4,17 +4,37 @@ import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types' import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools' +// Mock store interface matching the real store's nullable fields +interface MockMaskEditorStore { + maskCtx: CanvasRenderingContext2D | null + imgCtx: CanvasRenderingContext2D | null + maskCanvas: HTMLCanvasElement | null + imgCanvas: HTMLCanvasElement | null + rgbCtx: CanvasRenderingContext2D | null + rgbCanvas: HTMLCanvasElement | null + maskColor: { r: number; g: number; b: number } + paintBucketTolerance: number + fillOpacity: number + colorSelectTolerance: number + colorComparisonMethod: ColorComparisonMethod + selectionOpacity: number + applyWholeImage: boolean + maskBoundary: boolean + maskTolerance: number + canvasHistory: { saveState: ReturnType } +} + const mockCanvasHistory = { saveState: vi.fn() } -const mockStore = { - maskCtx: null as any, - imgCtx: null as any, - maskCanvas: null as any, - imgCanvas: null as any, - rgbCtx: null as any, - rgbCanvas: null as any, +const mockStore: MockMaskEditorStore = { + maskCtx: null, + imgCtx: null, + maskCanvas: null, + imgCanvas: null, + rgbCtx: null, + rgbCanvas: null, maskColor: { r: 255, g: 255, b: 255 }, paintBucketTolerance: 10, fillOpacity: 100, @@ -57,34 +77,40 @@ describe('useCanvasTools', () => { mockImgImageData.data[i + 3] = 255 } - mockStore.maskCtx = { + const partialMaskCtx: Partial = { getImageData: vi.fn(() => mockMaskImageData), putImageData: vi.fn(), clearRect: vi.fn() } + mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D - mockStore.imgCtx = { + const partialImgCtx: Partial = { getImageData: vi.fn(() => mockImgImageData) } + mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D - mockStore.rgbCtx = { + const partialRgbCtx: Partial = { clearRect: vi.fn() } + mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D - mockStore.maskCanvas = { + const partialMaskCanvas: Partial = { width: 100, height: 100 } + mockStore.maskCanvas = partialMaskCanvas as HTMLCanvasElement - mockStore.imgCanvas = { + const partialImgCanvas: Partial = { width: 100, height: 100 } + mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement - mockStore.rgbCanvas = { + const partialRgbCanvas: Partial = { width: 100, height: 100 } + mockStore.rgbCanvas = partialRgbCanvas as HTMLCanvasElement mockStore.maskColor = { r: 255, g: 255, b: 255 } mockStore.paintBucketTolerance = 10 @@ -103,13 +129,13 @@ describe('useCanvasTools', () => { tools.paintBucketFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith( + expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith( 0, 0, 100, 100 ) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith( + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith( mockMaskImageData, 0, 0 @@ -154,7 +180,7 @@ describe('useCanvasTools', () => { tools.paintBucketFill({ x: -1, y: 50 }) - expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled() }) it('should return early when canvas missing', () => { @@ -164,7 +190,7 @@ describe('useCanvasTools', () => { tools.paintBucketFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled() + expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled() }) it('should apply fill opacity', () => { @@ -198,14 +224,19 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith( + expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith( 0, 0, 100, 100 ) - expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.imgCtx!.getImageData).toHaveBeenCalledWith( + 0, + 0, + 100, + 100 + ) + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() expect(mockCanvasHistory.saveState).toHaveBeenCalled() }) @@ -216,7 +247,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should respect color tolerance', async () => { @@ -239,7 +270,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: -1, y: 50 }) - expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled() }) it('should return early when canvas missing', async () => { @@ -249,7 +280,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled() + expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled() }) it('should apply selection opacity', async () => { @@ -270,7 +301,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should use LAB color comparison method', async () => { @@ -280,7 +311,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should respect mask boundary', async () => { @@ -295,7 +326,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should update last color select point', async () => { @@ -303,7 +334,7 @@ describe('useCanvasTools', () => { await tools.colorSelectFill({ x: 30, y: 40 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) }) @@ -320,13 +351,13 @@ describe('useCanvasTools', () => { tools.invertMask() - expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith( + expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith( 0, 0, 100, 100 ) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith( + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith( mockMaskImageData, 0, 0 @@ -369,7 +400,7 @@ describe('useCanvasTools', () => { tools.invertMask() - expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled() + expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled() }) it('should return early when context missing', () => { @@ -389,8 +420,8 @@ describe('useCanvasTools', () => { tools.clearMask() - expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) - expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockStore.maskCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockStore.rgbCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) expect(mockCanvasHistory.saveState).toHaveBeenCalled() }) @@ -401,7 +432,7 @@ describe('useCanvasTools', () => { tools.clearMask() - expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled() + expect(mockStore.maskCtx?.clearRect).not.toHaveBeenCalled() expect(mockCanvasHistory.saveState).toHaveBeenCalled() }) @@ -412,8 +443,8 @@ describe('useCanvasTools', () => { tools.clearMask() - expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) - expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled() + expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockStore.rgbCtx?.clearRect).not.toHaveBeenCalled() expect(mockCanvasHistory.saveState).toHaveBeenCalled() }) }) @@ -426,26 +457,26 @@ describe('useCanvasTools', () => { tools.clearLastColorSelectPoint() - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) }) describe('edge cases', () => { it('should handle small canvas', () => { - mockStore.maskCanvas.width = 1 - mockStore.maskCanvas.height = 1 + mockStore.maskCanvas!.width = 1 + mockStore.maskCanvas!.height = 1 mockMaskImageData = { data: new Uint8ClampedArray(1 * 1 * 4), width: 1, height: 1 } as ImageData - mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData) + mockStore.maskCtx!.getImageData = vi.fn(() => mockMaskImageData) const tools = useCanvasTools() tools.paintBucketFill({ x: 0, y: 0 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should handle fractional coordinates', () => { @@ -453,7 +484,7 @@ describe('useCanvasTools', () => { tools.paintBucketFill({ x: 50.7, y: 50.3 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should handle maximum tolerance', () => { @@ -463,7 +494,7 @@ describe('useCanvasTools', () => { tools.paintBucketFill({ x: 50, y: 50 }) - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled() }) it('should handle zero opacity', () => { diff --git a/src/composables/maskeditor/useCanvasTransform.test.ts b/src/composables/maskeditor/useCanvasTransform.test.ts index 95c153d29..07ea0bb43 100644 --- a/src/composables/maskeditor/useCanvasTransform.test.ts +++ b/src/composables/maskeditor/useCanvasTransform.test.ts @@ -95,7 +95,7 @@ if (typeof globalThis.ImageData === 'undefined') { this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4) } } - } as unknown as typeof globalThis.ImageData + } as typeof ImageData } // Mock ImageBitmap for test environment using safe type casting @@ -108,7 +108,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') { this.height = height } close() {} - } as unknown as typeof globalThis.ImageBitmap + } as typeof ImageBitmap } describe('useCanvasTransform', () => { diff --git a/src/composables/maskeditor/useImageLoader.test.ts b/src/composables/maskeditor/useImageLoader.test.ts index ae4938366..b547ac917 100644 --- a/src/composables/maskeditor/useImageLoader.test.ts +++ b/src/composables/maskeditor/useImageLoader.test.ts @@ -2,22 +2,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useImageLoader } from '@/composables/maskeditor/useImageLoader' +type MockStore = { + imgCanvas: HTMLCanvasElement | null + maskCanvas: HTMLCanvasElement | null + rgbCanvas: HTMLCanvasElement | null + imgCtx: CanvasRenderingContext2D | null + maskCtx: CanvasRenderingContext2D | null + image: HTMLImageElement | null +} + +type MockDataStore = { + inputData: { + baseLayer: { image: HTMLImageElement } + maskLayer: { image: HTMLImageElement } + paintLayer: { image: HTMLImageElement } | null + } | null +} + const mockCanvasManager = { invalidateCanvas: vi.fn().mockResolvedValue(undefined), updateMaskColor: vi.fn().mockResolvedValue(undefined) } -const mockStore = { - imgCanvas: null as any, - maskCanvas: null as any, - rgbCanvas: null as any, - imgCtx: null as any, - maskCtx: null as any, - image: null as any +const mockStore: MockStore = { + imgCanvas: null, + maskCanvas: null, + rgbCanvas: null, + imgCtx: null, + maskCtx: null, + image: null } -const mockDataStore = { - inputData: null as any +const mockDataStore: MockDataStore = { + inputData: null } vi.mock('@/stores/maskEditorStore', () => ({ @@ -33,7 +50,8 @@ vi.mock('@/composables/maskeditor/useCanvasManager', () => ({ })) vi.mock('@vueuse/core', () => ({ - createSharedComposable: (fn: any) => fn + createSharedComposable: unknown>(fn: T) => + fn })) describe('useImageLoader', () => { @@ -61,26 +79,26 @@ describe('useImageLoader', () => { mockStore.imgCtx = { clearRect: vi.fn() - } + } as Partial as CanvasRenderingContext2D mockStore.maskCtx = { clearRect: vi.fn() - } + } as Partial as CanvasRenderingContext2D mockStore.imgCanvas = { width: 0, height: 0 - } + } as Partial as HTMLCanvasElement mockStore.maskCanvas = { width: 0, height: 0 - } + } as Partial as HTMLCanvasElement mockStore.rgbCanvas = { width: 0, height: 0 - } + } as Partial as HTMLCanvasElement mockDataStore.inputData = { baseLayer: { image: mockBaseImage }, @@ -104,10 +122,10 @@ describe('useImageLoader', () => { await loader.loadImages() - expect(mockStore.maskCanvas.width).toBe(512) - expect(mockStore.maskCanvas.height).toBe(512) - expect(mockStore.rgbCanvas.width).toBe(512) - expect(mockStore.rgbCanvas.height).toBe(512) + expect(mockStore.maskCanvas?.width).toBe(512) + expect(mockStore.maskCanvas?.height).toBe(512) + expect(mockStore.rgbCanvas?.width).toBe(512) + expect(mockStore.rgbCanvas?.height).toBe(512) }) it('should clear canvas contexts', async () => { @@ -115,8 +133,8 @@ describe('useImageLoader', () => { await loader.loadImages() - expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0) - expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0) + expect(mockStore.imgCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0) + expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0) }) it('should call canvasManager methods', async () => { @@ -188,10 +206,10 @@ describe('useImageLoader', () => { await loader.loadImages() - expect(mockStore.maskCanvas.width).toBe(1024) - expect(mockStore.maskCanvas.height).toBe(768) - expect(mockStore.rgbCanvas.width).toBe(1024) - expect(mockStore.rgbCanvas.height).toBe(768) + expect(mockStore.maskCanvas?.width).toBe(1024) + expect(mockStore.maskCanvas?.height).toBe(768) + expect(mockStore.rgbCanvas?.width).toBe(1024) + expect(mockStore.rgbCanvas?.height).toBe(768) }) }) }) diff --git a/src/composables/node/useNodeBadge.ts b/src/composables/node/useNodeBadge.ts index 2f31ad0ac..cfe2ad66f 100644 --- a/src/composables/node/useNodeBadge.ts +++ b/src/composables/node/useNodeBadge.ts @@ -71,8 +71,19 @@ export const useNodeBadge = () => { } onMounted(() => { + if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return + + // TODO: Fix the composables and watchers being setup in onMounted const nodePricing = useNodePricing() + watch( + () => nodePricing.pricingRevision.value, + () => { + if (!showApiPricingBadge.value) return + app.canvas?.setDirty(true, true) + } + ) + extensionStore.registerExtension({ name: 'Comfy.NodeBadge', nodeCreated(node: LGraphNode) { @@ -111,17 +122,16 @@ export const useNodeBadge = () => { node.badges.push(() => badge.value) if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) { - // Get the pricing function to determine if this node has dynamic pricing + // JSONata rules are dynamic if they depend on any widgets/inputs/input_groups const pricingConfig = nodePricing.getNodePricingConfig(node) const hasDynamicPricing = - typeof pricingConfig?.displayPrice === 'function' - - let creditsBadge - const createBadge = () => { - const price = nodePricing.getNodeDisplayPrice(node) - return priceBadge.getCreditsBadge(price) - } + !!pricingConfig && + ((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 || + (pricingConfig.depends_on?.inputs?.length ?? 0) > 0 || + (pricingConfig.depends_on?.input_groups?.length ?? 0) > 0) + // Keep the existing widget-watch wiring ONLY to trigger redraws on widget change. + // (We no longer rely on it to hold the current badge value.) if (hasDynamicPricing) { // For dynamic pricing nodes, use computed that watches widget changes const relevantWidgetNames = nodePricing.getRelevantWidgetNames( @@ -133,13 +143,63 @@ export const useNodeBadge = () => { triggerCanvasRedraw: true }) - creditsBadge = computedWithWidgetWatch(createBadge) - } else { - // For static pricing nodes, use regular computed - creditsBadge = computed(createBadge) + // Ensure watchers are installed; ignore the returned value. + // (This call is what registers the widget listeners in most implementations.) + computedWithWidgetWatch(() => 0) + + // Hook into connection changes to trigger price recalculation + // This handles both connect and disconnect in VueNodes mode + const relevantInputs = pricingConfig?.depends_on?.inputs ?? [] + const inputGroupPrefixes = + pricingConfig?.depends_on?.input_groups ?? [] + const hasRelevantInputs = + relevantInputs.length > 0 || inputGroupPrefixes.length > 0 + + if (hasRelevantInputs) { + const originalOnConnectionsChange = node.onConnectionsChange + node.onConnectionsChange = function ( + type, + slotIndex, + isConnected, + link, + ioSlot + ) { + originalOnConnectionsChange?.call( + this, + type, + slotIndex, + isConnected, + link, + ioSlot + ) + // Only trigger if this input affects pricing + const inputName = ioSlot?.name + if (!inputName) return + const isRelevantInput = + relevantInputs.includes(inputName) || + inputGroupPrefixes.some((prefix) => + inputName.startsWith(prefix + '.') + ) + if (isRelevantInput) { + nodePricing.triggerPriceRecalculation(node) + } + } + } } - node.badges.push(() => creditsBadge.value) + let lastLabel = nodePricing.getNodeDisplayPrice(node) + let lastBadge = priceBadge.getCreditsBadge(lastLabel) + + const creditsBadgeGetter: () => LGraphBadge = () => { + const label = nodePricing.getNodeDisplayPrice(node) + if (label !== lastLabel) { + lastLabel = label + lastBadge = priceBadge.getCreditsBadge(label) + } + return lastBadge + } + + node.badges.push(creditsBadgeGetter) } }, init() { diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index c811a4796..2f45258fb 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -1,2478 +1,857 @@ import { describe, expect, it } from 'vitest' -import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' +import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' import { useNodePricing } from '@/composables/node/useNodePricing' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +import type { PriceBadge } from '@/schemas/nodeDefSchema' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' -// Helper function to create a mock node -function createMockNode( +// ----------------------------------------------------------------------------- +// Test Types +// ----------------------------------------------------------------------------- + +interface MockNodeWidget { + name: string + value: unknown + type: string +} + +interface MockNodeInput { + name: string + link: number | null +} + +interface MockNodeData { + name: string + api_node: boolean + price_badge?: PriceBadge +} + +// ----------------------------------------------------------------------------- +// Test Helpers +// ----------------------------------------------------------------------------- + +/** + * Determine if a number should display 1 decimal place. + * Shows decimal only when the first decimal digit is non-zero. + */ +const shouldShowDecimal = (value: number): boolean => { + const rounded = Math.round(value * 10) / 10 + return rounded % 1 !== 0 +} + +const creditValue = (usd: number): string => { + const rawCredits = usd * CREDITS_PER_USD + return formatCredits({ + value: rawCredits, + numberOptions: { + minimumFractionDigits: 0, + maximumFractionDigits: shouldShowDecimal(rawCredits) ? 1 : 0 + } + }) +} + +const creditsLabel = (usd: number, suffix = '/Run'): string => + `${creditValue(usd)} credits${suffix}` + +/** + * Create a mock node with price_badge for testing JSONata-based pricing. + */ +function createMockNodeWithPriceBadge( nodeTypeName: string, - widgets: Array<{ name: string; value: any }> = [], - isApiNode = true, - inputs: Array<{ - name: string - connected?: boolean - useLinksArray?: boolean - }> = [] + priceBadge: PriceBadge, + widgets: Array<{ name: string; value: unknown }> = [], + inputs: Array<{ name: string; connected?: boolean }> = [] ): LGraphNode { const mockWidgets = widgets.map(({ name, value }) => ({ name, value, type: 'combo' - })) as IComboWidget[] + })) - const mockInputs = - inputs.length > 0 - ? inputs.map(({ name, connected, useLinksArray }) => - useLinksArray - ? { name, links: connected ? [1] : [] } - : { name, link: connected ? 1 : null } - ) - : undefined + const mockInputs: MockNodeInput[] = inputs.map(({ name, connected }) => ({ + name, + link: connected ? 1 : null + })) - const node: any = { - id: Math.random().toString(), + const baseNode = createMockLGraphNode() + return Object.assign(baseNode, { widgets: mockWidgets, + inputs: mockInputs, constructor: { nodeData: { name: nodeTypeName, - api_node: isApiNode + api_node: true, + price_badge: priceBadge } } - } - - if (mockInputs) { - node.inputs = mockInputs - // Provide the common helpers some frontend code may call - node.findInputSlot = function (portName: string) { - return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1 - } - node.isInputConnected = function (idx: number) { - const port = this.inputs?.[idx] - if (!port) return false - if (typeof port.link !== 'undefined') return port.link != null - if (Array.isArray(port.links)) return port.links.length > 0 - return false - } - } - - return node as LGraphNode + }) } -describe('useNodePricing', () => { - describe('static pricing', () => { - it('should return static price for FluxProCannyNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('FluxProCannyNode') +/** Helper to create a price badge with defaults */ +const priceBadge = ( + expr: string, + widgets: Array<{ name: string; type: string }> = [], + inputs: string[] = [], + inputGroups: string[] = [] +): PriceBadge => ({ + engine: 'jsonata', + expr, + depends_on: { widgets, inputs, input_groups: inputGroups } +}) +/** Helper to create a mock node for edge case testing */ +function createMockNode( + nodeData: MockNodeData, + widgets: MockNodeWidget[] = [], + inputs: MockNodeInput[] = [] +): LGraphNode { + const baseNode = createMockLGraphNode() + return Object.assign(baseNode, { + widgets, + inputs, + constructor: { nodeData } + }) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe('useNodePricing', () => { + describe('static expressions', () => { + it('should evaluate simple static USD price', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestStaticNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.05)) }) - it('should return static price for StabilityStableImageUltraNode', () => { + it('should evaluate static text result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('StabilityStableImageUltraNode') + const node = createMockNodeWithPriceBadge( + 'TestTextNode', + priceBadge('{"type":"text","text":"Free"}') + ) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return empty string for non-API nodes', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RegularNode', [], false) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('') - }) - - it('should return empty string for unknown node types', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('UnknownAPINode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe('') + expect(price).toBe('Free') }) }) - describe('dynamic pricing - Flux2ProImageNode', () => { - it('should return precise price for text-to-image 1024x1024 (no refs)', () => { + describe('widget value normalization', () => { + it('should handle INT widget as number', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Flux2ProImageNode', [ - { name: 'width', value: 1024 }, - { name: 'height', value: 1024 } - ]) - - // 1024x1024 => 1 MP => $0.03 - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.03)) - }) - - it('should return minimum estimate when refs are connected (1024x1024)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode( - 'Flux2ProImageNode', - [ - { name: 'width', value: 1024 }, - { name: 'height', value: 1024 } - ], - true, - // connect the 'images' input - [{ name: 'images', connected: true }] + const node = createMockNodeWithPriceBadge( + 'TestIntNode', + priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [ + { name: 'count', type: 'INT' } + ]), + [{ name: 'count', value: 5 }] ) - // 1024x1024 => 1 MP output = $0.03, min input add = $0.015 => ~$0.045 min - expect(getNodeDisplayPrice(node)).toBe( - creditsRangeLabel(0.045, 0.15, { approximate: true }) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle FLOAT widget as number', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestFloatNode', + priceBadge('{"type":"usd","usd": widgets.rate * 10}', [ + { name: 'rate', type: 'FLOAT' } + ]), + [{ name: 'rate', value: 0.05 }] ) - }) - - it('should show fallback when width/height are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Flux2ProImageNode', []) - expect(getNodeDisplayPrice(node)).toBe(creditsRangeLabel(0.03, 0.15)) - }) - }) - - describe('dynamic pricing - KlingTextToVideoNode', () => { - it('should return high price for kling-v2-1-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v2-1-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return low price for kling-v2-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'pro / 5s / v2-5-turbo' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.35)) - }) - - it('should return high price for kling-v2-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'pro / 10s / v2-5-turbo' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.7)) - }) - - it('should return standard price for kling-v1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', [ - { name: 'mode', value: 'standard / 5s / v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return range when mode widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - KlingImage2VideoNode', () => { - it('should return high price for kling-v2-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-1-master model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-1-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.4)) - }) - - it('should return high price for kling-v2-5-turbo model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v2-5-turbo' }, - { name: 'mode', value: 'pro mode / 10s duration / kling-v2-5-turbo' }, - { name: 'duration', value: '10' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.7)) - }) - - it('should return standard price for kling-v1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', [ - { name: 'model_name', value: 'v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return range when model_name widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImage2VideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIDalle3', () => { - it('should return $0.04 for 1024x1024 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04)) - }) - - it('should return $0.08 for 1024x1024 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.08 for 1792x1024 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1792x1024' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.16 for 1792x1024 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1792x1024' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) - }) - - it('should return $0.08 for 1024x1792 standard quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1792' }, - { name: 'quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.08)) - }) - - it('should return $0.16 for 1024x1792 hd quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1792' }, - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) - }) - - it('should return range when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - - it('should return range when size widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'quality', value: 'hd' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - - it('should return range when quality widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle3', [ - { name: 'size', value: '1024x1024' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) - ) - }) - }) - // ============================== OpenAIVideoSora2 ============================== - describe('dynamic pricing - OpenAIVideoSora2', () => { - it('should require model, duration & size when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', []) - expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size') - }) - - it('should require duration when duration is invalid or zero', () => { - const { getNodeDisplayPrice } = useNodePricing() - const nodeNaN = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 'oops' }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(nodeNaN)).toBe('Set model, duration & size') - - const nodeZero = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 0 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(nodeZero)).toBe('Set model, duration & size') - }) - - it('should require size when size is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 } - ]) - expect(getNodeDisplayPrice(node)).toBe('Set model, duration & size') - }) - - it('should compute pricing for sora-2-pro with 1024x1792', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '1024x1792' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(4.0)) // 0.5 * 8 - }) - - it('should compute pricing for sora-2-pro with 720x1280', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 12 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 - }) - - it('should reject unsupported size for sora-2-pro', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '640x640' } - ]) - expect(getNodeDisplayPrice(node)).toBe( - 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.' - ) - }) - - it('should compute pricing for sora-2 (720x1280 only)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2' }, - { name: 'duration', value: 10 }, - { name: 'size', value: '720x1280' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(1.0)) // 0.1 * 10 - }) - - it('should reject non-720 sizes for sora-2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2' }, - { name: 'duration', value: 8 }, - { name: 'size', value: '1024x1792' } - ]) - expect(getNodeDisplayPrice(node)).toBe( - 'sora-2 supports only 720x1280 or 1280x720' - ) - }) - it('should accept duration_s alias for duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'sora-2-pro' }, - { name: 'duration_s', value: 4 }, - { name: 'size', value: '1792x1024' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(2.0)) // 0.5 * 4 - }) - - it('should be case-insensitive for model and size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIVideoSora2', [ - { name: 'model', value: 'SoRa-2-PrO' }, - { name: 'duration', value: 12 }, - { name: 'size', value: '1280x720' } - ]) - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(3.6)) // 0.3 * 12 - }) - }) - - // ============================== MinimaxHailuoVideoNode ============================== - describe('dynamic pricing - MinimaxHailuoVideoNode', () => { - it('should return $0.28 for 6s duration and 768P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '6' }, - { name: 'resolution', value: '768P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return $0.60 for 10s duration and 768P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '768P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.56)) - }) - - it('should return $0.49 for 6s duration and 1080P resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', [ - { name: 'duration', value: '6' }, - { name: 'resolution', value: '1080P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.49)) - }) - - it('should return range when duration widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('MinimaxHailuoVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.28, 0.56, { - note: '(varies with resolution & duration)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIDalle2', () => { - it('should return $0.02 for 1024x1024 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '1024x1024' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.02)) - }) - - it('should return $0.018 for 512x512 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '512x512' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.018)) - }) - - it('should return $0.016 for 256x256 size', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '256x256' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.016)) - }) - - it('should return range when size widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.016, 0.02, { - suffix: ' x n/Run', - note: '(varies with size & n)' - }) - ) - }) - }) - - describe('dynamic pricing - OpenAIGPTImage1', () => { - it('should return high price range for high quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'high' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.167, 0.3)) - }) - - it('should return medium price range for medium quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'medium' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.046, 0.07)) - }) - - it('should return low price range for low quality', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'low' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.011, 0.02)) - }) - - it('should return range when quality widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.011, 0.3, { - suffix: ' x n/Run', - note: '(varies with quality & n)' - }) - ) - }) - }) - - describe('dynamic pricing - IdeogramV3', () => { - it('should return correct prices for IdeogramV3 node', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - rendering_speed: 'Quality', - character_image: false, - expected: creditsLabel(0.13) - }, - { - rendering_speed: 'Quality', - character_image: true, - expected: creditsLabel(0.29) - }, - { - rendering_speed: 'Default', - character_image: false, - expected: creditsLabel(0.09) - }, - { - rendering_speed: 'Default', - character_image: true, - expected: creditsLabel(0.21) - }, - { - rendering_speed: 'Turbo', - character_image: false, - expected: creditsLabel(0.04) - }, - { - rendering_speed: 'Turbo', - character_image: true, - expected: creditsLabel(0.14) - } - ] - - testCases.forEach(({ rendering_speed, character_image, expected }) => { - const node = createMockNode( - 'IdeogramV3', - [{ name: 'rendering_speed', value: rendering_speed }], - true, - [{ name: 'character_image', connected: character_image }] - ) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return range when rendering_speed widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.11, { - suffix: ' x num_images/Run', - note: '(varies with rendering speed & num_images)' - }) - ) - }) - - it('should multiply price by num_images for Quality rendering speed', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', [ - { name: 'rendering_speed', value: 'Quality' }, - { name: 'num_images', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.39)) // 0.09 * 3 * 1.43 - }) - - it('should multiply price by num_images for Turbo rendering speed', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV3', [ - { name: 'rendering_speed', value: 'Turbo' }, - { name: 'num_images', value: 5 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.21)) // 0.03 * 5 * 1.43 - }) - }) - - describe('dynamic pricing - VeoVideoGenerationNode', () => { - it('should return $5.00 for 10s duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', [ - { name: 'duration_seconds', value: '10' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(5.0)) - }) - - it('should return $2.50 for 5s duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', [ - { name: 'duration_seconds', value: '5' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(2.5)) - }) - - it('should return range when duration widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('VeoVideoGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(2.5, 5.0, { note: '(varies with duration)' }) - ) - }) - }) - - describe('dynamic pricing - Veo3VideoGenerationNode', () => { - it('should return $0.80 for veo-3.0-fast-generate-001 without audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-fast-generate-001' }, - { name: 'generate_audio', value: false } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.8)) - }) - - it('should return $1.20 for veo-3.0-fast-generate-001 with audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-fast-generate-001' }, - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.2)) - }) - - it('should return $1.60 for veo-3.0-generate-001 without audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' }, - { name: 'generate_audio', value: false } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.6)) - }) - - it('should return $3.20 for veo-3.0-generate-001 with audio', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' }, - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(3.2)) - }) - - it('should return range when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - - it('should return range when only model widget is present', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'model', value: 'veo-3.0-generate-001' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - - it('should return range when only generate_audio widget is present', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Veo3VideoGenerationNode', [ - { name: 'generate_audio', value: true } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.8, 3.2, { - note: 'varies with model & audio generation' - }) - ) - }) - }) - - describe('dynamic pricing - LumaVideoNode', () => { - it('should return $2.19 for ray-flash-2 4K 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-flash-2' }, - { name: 'resolution', value: '4K' }, - { name: 'duration', value: '5s' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(3.13)) - }) - - it('should return $6.37 for ray-2 4K 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-2' }, - { name: 'resolution', value: '4K' }, - { name: 'duration', value: '5s' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(9.11)) - }) - - it('should return $0.35 for ray-1-6 model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', [ - { name: 'model', value: 'ray-1-6' }, - { name: 'resolution', value: '1080p' }, - { name: 'duration', value: '5s' } - ]) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) expect(price).toBe(creditsLabel(0.5)) }) - it('should return range when widgets are missing', () => { + it('should handle COMBO widget with numeric value', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LumaVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.2, 16.4, { - note: 'varies with model, resolution & duration' - }) + const node = createMockNodeWithPriceBadge( + 'TestComboNumericNode', + priceBadge('{"type":"usd","usd": widgets.duration * 0.07}', [ + { name: 'duration', type: 'COMBO' } + ]), + [{ name: 'duration', value: 5 }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.35)) + }) + + it('should handle COMBO widget with string value', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestComboStringNode', + priceBadge( + '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [{ name: 'mode', type: 'COMBO' }] + ), + [{ name: 'mode', value: 'Pro' }] // Should be lowercased to "pro" + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle BOOLEAN widget', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestBooleanNode', + priceBadge('{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}', [ + { name: 'premium', type: 'BOOLEAN' } + ]), + [{ name: 'premium', value: true }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle STRING widget (lowercased)', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestStringNode', + priceBadge( + '$contains(widgets.model, "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [{ name: 'model', type: 'STRING' }] + ), + [{ name: 'model', value: 'ProModel' }] // Should be lowercased to "promodel" + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) }) }) - describe('dynamic pricing - PixverseTextToVideoNode', () => { - it('should return range for 5s 1080p quality', () => { + describe('complex expressions', () => { + it('should handle lookup tables', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', [ - { name: 'duration', value: '5s' }, - { name: 'quality', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestLookupNode', + priceBadge( + `( + $rates := {"720p": 0.05, "1080p": 0.10}; + {"type":"usd","usd": $lookup($rates, widgets.resolution)} + )`, + [{ name: 'resolution', type: 'COMBO' }] + ), + [{ name: 'resolution', value: '1080p' }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) }) - it('should return range for 5s 540p normal quality', () => { + it('should handle multiple widgets', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', [ - { name: 'duration', value: '5s' }, - { name: 'quality', value: '540p' }, - { name: 'motion_mode', value: 'normal' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestMultiWidgetNode', + priceBadge( + `( + $rate := (widgets.mode = "pro") ? 0.10 : 0.05; + {"type":"usd","usd": $rate * widgets.duration} + )`, + [ + { name: 'mode', type: 'COMBO' }, + { name: 'duration', type: 'INT' } + ] + ), + [ + { name: 'mode', value: 'pro' }, + { name: 'duration', value: 10 } + ] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(1.0)) }) - it('should return range when widgets are missing', () => { + it('should handle conditional pricing based on widget values', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('PixverseTextToVideoNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.45, 1.2, { - note: 'varies with duration, quality & motion mode' - }) + const node = createMockNodeWithPriceBadge( + 'TestConditionalNode', + priceBadge( + `( + $mode := (widgets.resolution = "720p") ? "std" : "pro"; + $rates := {"std": 0.084, "pro": 0.112}; + {"type":"usd","usd": $lookup($rates, $mode) * widgets.duration} + )`, + [ + { name: 'resolution', type: 'COMBO' }, + { name: 'duration', type: 'COMBO' } + ] + ), + [ + { name: 'resolution', value: '1080p' }, + { name: 'duration', value: 5 } + ] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.56)) }) }) - describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => { - it('should return range for v2-master 5s mode', () => { + describe('range and list results', () => { + it('should format range_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', [ - { name: 'mode', value: 'standard / 5s / v2-master' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestRangeNode', + priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":0.10}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d*-\d+\.?\d* credits\/Run/) }) - it('should return range for v1-6 5s mode', () => { + it('should format list_usd result', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', [ - { name: 'mode', value: 'standard / 5s / v1-6' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestListNode', + priceBadge('{"type":"list_usd","usd":[0.05, 0.10, 0.15]}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/) }) - it('should return range when mode widget is missing', () => { + it('should respect custom suffix in format options', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingDualCharacterVideoEffectNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestSuffixNode', + priceBadge('{"type":"usd","usd":0.07,"format":{"suffix":"/second"}}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.07, '/second')) + }) + + it('should add approximate prefix when specified', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestApproximateNode', + priceBadge('{"type":"usd","usd":0.05,"format":{"approximate":true}}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/^~\d+\.?\d* credits\/Run$/) + }) + + it('should add note suffix when specified', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNoteNode', + priceBadge('{"type":"usd","usd":0.05,"format":{"note":"(estimated)"}}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/credits\/Run \(estimated\)$/) + }) + + it('should combine approximate prefix and note suffix', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestCombinedFormatNode', + priceBadge( + '{"type":"usd","usd":0.05,"format":{"approximate":true,"note":"(beta)","suffix":"/image"}}' + ) + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/^~\d+\.?\d* credits\/image \(beta\)$/) + }) + + it('should use custom separator for list_usd', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestListSeparatorNode', + priceBadge( + '{"type":"list_usd","usd":[0.05, 0.10],"format":{"separator":" or "}}' + ) + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toMatch(/\d+\.?\d* or \d+\.?\d* credits\/Run/) }) }) - describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => { - it('should return $0.28 for fuzzyfuzzy effect', () => { + describe('input connectivity', () => { + it('should handle connected input check', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', [ - { name: 'effect_scene', value: 'fuzzyfuzzy' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.28)) - }) - - it('should return $0.49 for dizzydizzy effect', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', [ - { name: 'effect_scene', value: 'dizzydizzy' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.49)) - }) - - it('should return range when effect_scene widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingSingleImageVideoEffectNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.28, 0.49, { note: '(varies with effect scene)' }) + const node = createMockNodeWithPriceBadge( + 'TestInputNode', + priceBadge( + 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [], + ['image'] + ), + [], + [{ name: 'image', connected: true }] ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.1)) + }) + + it('should handle disconnected input check', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestInputDisconnectedNode', + priceBadge( + 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}', + [], + ['image'] + ), + [], + [{ name: 'image', connected: false }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + }) + + describe('edge cases', () => { + it('should return empty string for non-API nodes', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode({ + name: 'RegularNode', + api_node: false + }) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for nodes without price_badge', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode({ + name: 'ApiNodeNoPricing', + api_node: true + }) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should handle null widget value gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNullWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [{ name: 'count', value: null }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle missing widget gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestMissingWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + + it('should handle undefined widget value gracefully', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestUndefinedWidgetNode', + priceBadge( + '{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.05}', + [{ name: 'count', type: 'INT' }] + ), + [{ name: 'count', value: undefined }] + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe(creditsLabel(0.05)) + }) + }) + + describe('getNodePricingConfig', () => { + it('should return pricing config for nodes with price_badge', () => { + const { getNodePricingConfig } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestConfigNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + const config = getNodePricingConfig(node) + expect(config).toBeDefined() + expect(config?.engine).toBe('jsonata') + expect(config?.expr).toBe('{"type":"usd","usd":0.05}') + expect(config?.depends_on).toBeDefined() + }) + + it('should return undefined for nodes without price_badge', () => { + const { getNodePricingConfig } = useNodePricing() + const node = createMockNode({ + name: 'NoPricingNode', + api_node: true + }) + + const config = getNodePricingConfig(node) + expect(config).toBeUndefined() + }) + + it('should return undefined for non-API nodes', () => { + const { getNodePricingConfig } = useNodePricing() + const node = createMockNode({ + name: 'RegularNode', + api_node: false + }) + + const config = getNodePricingConfig(node) + expect(config).toBeUndefined() + }) + }) + + describe('getNodeRevisionRef', () => { + it('should return a ref for a node ID', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref = getNodeRevisionRef('node-1') + + expect(ref).toBeDefined() + expect(ref.value).toBe(0) + }) + + it('should return the same ref for the same node ID', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref1 = getNodeRevisionRef('node-same') + const ref2 = getNodeRevisionRef('node-same') + + expect(ref1).toBe(ref2) + }) + + it('should return different refs for different node IDs', () => { + const { getNodeRevisionRef } = useNodePricing() + const ref1 = getNodeRevisionRef('node-a') + const ref2 = getNodeRevisionRef('node-b') + + expect(ref1).not.toBe(ref2) + }) + + it('should handle both string and number node IDs', () => { + const { getNodeRevisionRef } = useNodePricing() + // Number ID gets stringified, so '123' and 123 should return the same ref + const refFromNumber = getNodeRevisionRef(123) + const refFromString = getNodeRevisionRef('123') + + expect(refFromNumber).toBe(refFromString) + }) + }) + + describe('triggerPriceRecalculation', () => { + it('should not throw for API nodes with price_badge', () => { + const { triggerPriceRecalculation } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestTriggerNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + expect(() => triggerPriceRecalculation(node)).not.toThrow() + }) + + it('should not throw for non-API nodes', () => { + const { triggerPriceRecalculation } = useNodePricing() + const node = createMockNode({ + name: 'RegularNode', + api_node: false + }) + + expect(() => triggerPriceRecalculation(node)).not.toThrow() }) }) describe('error handling', () => { - it('should gracefully handle errors in dynamic pricing functions', () => { + it('should return empty string for invalid JSONata expression', async () => { const { getNodeDisplayPrice } = useNodePricing() - // Create a node with malformed widget data that could cause errors - const node = { - id: 'test-node', - widgets: null, // This could cause errors when accessing .find() - constructor: { - nodeData: { - name: 'KlingTextToVideoNode', - api_node: true - } - } - } as unknown as LGraphNode - - // Should not throw an error and return empty string as fallback - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) + const node = createMockNodeWithPriceBadge( + 'TestInvalidExprNode', + // Invalid JSONata syntax (unclosed parenthesis) + priceBadge('{"type":"usd","usd": (widgets.count * 0.01') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + // Should not crash, just return empty + expect(price).toBe('') }) - it('should handle completely broken widget structure', () => { + it('should return empty string for expression that throws at runtime', async () => { const { getNodeDisplayPrice } = useNodePricing() - // Create a node with no widgets property at all - const node = { - id: 'test-node', - // No widgets property - constructor: { - nodeData: { - name: 'OpenAIDalle3', - api_node: true - } - } - } as unknown as LGraphNode - - // Should gracefully fall back to the default range - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.04, 0.12, { note: '(varies with size & quality)' }) + const node = createMockNodeWithPriceBadge( + 'TestRuntimeErrorNode', + // Expression that will fail at runtime (calling function on undefined) + priceBadge('$lookup(undefined, "key")') ) - }) - }) - - describe('helper methods', () => { - describe('getNodePricingConfig', () => { - it('should return pricing config for known API nodes', () => { - const { getNodePricingConfig } = useNodePricing() - const node = createMockNode('KlingTextToVideoNode') - - const config = getNodePricingConfig(node) - expect(config).toBeDefined() - expect(typeof config.displayPrice).toBe('function') - }) - - it('should return undefined for unknown nodes', () => { - const { getNodePricingConfig } = useNodePricing() - const node = createMockNode('UnknownNode') - - const config = getNodePricingConfig(node) - expect(config).toBeUndefined() - }) - }) - - describe('getRelevantWidgetNames', () => { - it('should return correct widget names for KlingTextToVideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('KlingTextToVideoNode') - expect(widgetNames).toEqual(['mode', 'model_name', 'duration']) - }) - - it('should return correct widget names for KlingImage2VideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('KlingImage2VideoNode') - expect(widgetNames).toEqual(['mode', 'model_name', 'duration']) - }) - - it('should return correct widget names for OpenAIDalle3', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('OpenAIDalle3') - expect(widgetNames).toEqual(['size', 'quality']) - }) - - it('should return correct widget names for VeoVideoGenerationNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('VeoVideoGenerationNode') - expect(widgetNames).toEqual(['duration_seconds']) - }) - - it('should return correct widget names for Veo3VideoGenerationNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('Veo3VideoGenerationNode') - expect(widgetNames).toEqual(['model', 'generate_audio']) - }) - - it('should return correct widget names for LumaVideoNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('LumaVideoNode') - expect(widgetNames).toEqual(['model', 'resolution', 'duration']) - }) - - it('should return correct widget names for KlingSingleImageVideoEffectNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames( - 'KlingSingleImageVideoEffectNode' - ) - expect(widgetNames).toEqual(['effect_scene']) - }) - - it('should return empty array for unknown node types', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('UnknownNode') - expect(widgetNames).toEqual([]) - }) - - describe('Ideogram nodes with num_images parameter', () => { - it('should return correct widget names for IdeogramV1', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV1') - expect(widgetNames).toEqual(['num_images', 'turbo']) - }) - - it('should return correct widget names for IdeogramV2', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV2') - expect(widgetNames).toEqual(['num_images', 'turbo']) - }) - - it('should return correct widget names for IdeogramV3', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('IdeogramV3') - expect(widgetNames).toEqual([ - 'rendering_speed', - 'num_images', - 'character_image' - ]) - }) - }) - - describe('Recraft nodes with n parameter', () => { - it('should return correct widget names for RecraftTextToImageNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('RecraftTextToImageNode') - expect(widgetNames).toEqual(['n']) - }) - - it('should return correct widget names for RecraftTextToVectorNode', () => { - const { getRelevantWidgetNames } = useNodePricing() - - const widgetNames = getRelevantWidgetNames('RecraftTextToVectorNode') - expect(widgetNames).toEqual(['n']) - }) - }) - }) - - describe('Ideogram nodes dynamic pricing', () => { - it('should calculate dynamic pricing for IdeogramV1 based on num_images value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', [ - { name: 'num_images', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.26)) // 0.06 * 3 * 1.43 - }) - - it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV2', [ - { name: 'num_images', value: 4 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.46)) // 0.08 * 4 * 1.43 - }) - - it('should fall back to static display when num_images widget is missing for IdeogramV1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.03, 0.09, { suffix: ' x num_images/Run' }) - ) - }) - - it('should fall back to static display when num_images widget is missing for IdeogramV2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV2', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.07, 0.11, { suffix: ' x num_images/Run' }) - ) - }) - - it('should handle edge case when num_images value is 1 for IdeogramV1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('IdeogramV1', [ - { name: 'num_images', value: 1 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.09)) // 0.06 * 1 * 1.43 (turbo=false by default) - }) - }) - - describe('Recraft nodes dynamic pricing', () => { - it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToImageNode', [ - { name: 'n', value: 3 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 - }) - - it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToVectorNode', [ - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 - }) - - it('should fall back to static display when n widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftTextToImageNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04, { suffix: ' x n/Run' })) - }) - - it('should handle edge case when n value is 1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftImageInpaintingNode', [ - { name: 'n', value: 1 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.04)) // 0.04 * 1 - }) - }) - }) - - describe('OpenAI nodes dynamic pricing with n parameter', () => { - it('should calculate dynamic pricing for OpenAIDalle2 based on size and n', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '1024x1024' }, - { name: 'n', value: 3 } - ]) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.06)) // 0.02 * 3 + expect(price).toBe('') }) - it('should calculate dynamic pricing for OpenAIGPTImage1 based on quality and n', () => { + it('should return empty string for invalid PricingResult type', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIGPTImage1', [ - { name: 'quality', value: 'low' }, - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.011, 0.02, { suffix: ' x 2/Run' })) - }) - - it('should fall back to static display when n widget is missing for OpenAIDalle2', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIDalle2', [ - { name: 'size', value: '512x512' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.018)) // n defaults to 1 - }) - }) - - describe('KlingImageGenerationNode dynamic pricing with n parameter', () => { - it('should calculate dynamic pricing for text-to-image with kling-v1', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImageGenerationNode', [ - { name: 'model_name', value: 'kling-v1' }, - { name: 'n', value: 4 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.014)) // 0.0035 * 4 - }) - - it('should calculate dynamic pricing for text-to-image with kling-v1-5', () => { - const { getNodeDisplayPrice } = useNodePricing() - // Mock node without image input (text-to-image mode) - const node = createMockNode('KlingImageGenerationNode', [ - { name: 'model_name', value: 'kling-v1-5' }, - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.028)) // For kling-v1-5 text-to-image: 0.014 * 2 - }) - - it('should fall back to static display when model widget is missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('KlingImageGenerationNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.0035, 0.028, { - suffix: ' x n/Run', - note: '(varies with modality & model)' - }) + const node = createMockNodeWithPriceBadge( + 'TestInvalidResultTypeNode', + // Returns object with invalid type field + priceBadge('{"type":"invalid_type","value":123}') ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for PricingResult missing type field', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestMissingTypeNode', + // Returns object without type field + priceBadge('{"usd":0.05}') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for non-object result', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNonObjectNode', + // Returns a plain number instead of PricingResult object + priceBadge('0.05') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') + }) + + it('should return empty string for null result', async () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestNullResultNode', + priceBadge('null') + ) + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('') }) }) - describe('New Recraft nodes dynamic pricing', () => { - it('should calculate dynamic pricing for RecraftGenerateImageNode', () => { + describe('input_groups connectivity', () => { + it('should count connected inputs in a group', async () => { const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftGenerateImageNode', [ - { name: 'n', value: 3 } - ]) - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.12)) // 0.04 * 3 - }) - - it('should calculate dynamic pricing for RecraftVectorizeImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftVectorizeImageNode', [ - { name: 'n', value: 5 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.05)) // 0.01 * 5 - }) - - it('should calculate dynamic pricing for RecraftGenerateVectorImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RecraftGenerateVectorImageNode', [ - { name: 'n', value: 2 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.16)) // 0.08 * 2 - }) - }) - - describe('Widget names for reactive updates', () => { - it('should include n parameter for OpenAI nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('OpenAIDalle2')).toEqual(['size', 'n']) - expect(getRelevantWidgetNames('OpenAIGPTImage1')).toEqual([ - 'quality', - 'n' - ]) - }) - - it('should include n parameter for Kling and new Recraft nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('KlingImageGenerationNode')).toEqual([ - 'modality', - 'model_name', - 'n' - ]) - expect(getRelevantWidgetNames('RecraftVectorizeImageNode')).toEqual(['n']) - expect(getRelevantWidgetNames('RecraftGenerateImageNode')).toEqual(['n']) - expect(getRelevantWidgetNames('RecraftGenerateVectorImageNode')).toEqual([ - 'n' - ]) - expect( - getRelevantWidgetNames('RecraftGenerateColorFromImageNode') - ).toEqual(['n']) - }) - - it('should include relevant widget names for new nodes', () => { - const { getRelevantWidgetNames } = useNodePricing() - - expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([ - 'duration' - ]) - expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ]) - expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ]) - }) - }) - - describe('New API nodes pricing', () => { - describe('RunwayML nodes', () => { - it('should return static price for RunwayTextToImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayTextToImageNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.11)) - }) - - it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 10 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715 * 10)) - }) - - it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715, { suffix: '/second' })) - }) - - it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 0 } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0)) // 0.05 * 0 = 0 - }) - - it('should handle NaN duration for RunwayImageToVideoNodeGen3a', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 'invalid' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.0715 * 5)) - }) - }) - - describe('Rodin nodes', () => { - it('should return base price for Rodin3D_Regular', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Regular') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - - it('should return addon price for Rodin3D_Detail', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Detail') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - - it('should return addon price for Rodin3D_Smooth', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('Rodin3D_Smooth') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) - }) - }) - - describe('Tripo nodes', () => { - it('should return v2.5 standard pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.5' }, - { name: 'quad', value: false }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.15)) // any style, no quad, no texture - }) - - it('should return v2.5 detailed pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.5' }, - { name: 'quad', value: true }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'detailed' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) // any style, quad, no texture, detailed - }) - - it('should return v2.0 detailed pricing for TripoImageToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoImageToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: true }, - { name: 'style', value: 'any style' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'detailed' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.4)) // any style, quad, no texture, detailed - }) - - it('should return legacy pricing for TripoTextToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: false }, - { name: 'style', value: 'none' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.1)) // none style, no quad, no texture - }) - - it('should return static price for TripoRefineNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoRefineNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) - }) - - it('should return fallback for TripoTextToModelNode without model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) - }) - - it('should return texture-based pricing for TripoTextureNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const standardNode = createMockNode('TripoTextureNode', [ - { name: 'texture_quality', value: 'standard' } - ]) - const detailedNode = createMockNode('TripoTextureNode', [ - { name: 'texture_quality', value: 'detailed' } - ]) - - expect(getNodeDisplayPrice(standardNode)).toBe(creditsLabel(0.1)) - expect(getNodeDisplayPrice(detailedNode)).toBe(creditsLabel(0.2)) - }) - - it('should handle various Tripo parameter combinations', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test different parameter combinations - const testCases = [ - { - model_version: 'v3.0', - quad: false, - style: 'none', - texture: false, - expected: creditsLabel(0.1) - }, - { - model_version: 'v3.0', - quad: false, - style: 'any style', - texture: false, - expected: creditsLabel(0.15) - }, - { - model_version: 'v3.0', - quad: true, - style: 'any style', - texture: false, - expected: creditsLabel(0.2) - }, - { - model_version: 'v3.0', - quad: true, - style: 'any style', - texture: true, - expected: creditsLabel(0.3) - } - ] - - testCases.forEach(({ quad, style, texture, expected }) => { - const node = createMockNode('TripoTextToModelNode', [ - { name: 'model_version', value: 'v2.0' }, - { name: 'quad', value: quad }, - { name: 'style', value: style }, - { name: 'texture', value: texture }, - { name: 'texture_quality', value: 'standard' } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return static price for TripoRetargetNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoRetargetNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.1)) - }) - - it('should return dynamic pricing for TripoMultiviewToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test basic case - no style, no quad, no texture - const basicNode = createMockNode('TripoMultiviewToModelNode', [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: false }, - { name: 'style', value: 'none' }, - { name: 'texture', value: false }, - { name: 'texture_quality', value: 'standard' } - ]) - expect(getNodeDisplayPrice(basicNode)).toBe(creditsLabel(0.2)) - - // Test high-end case - any style, quad, texture, detailed - const highEndNode = createMockNode('TripoMultiviewToModelNode', [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: true }, - { name: 'style', value: 'stylized' }, - { name: 'texture', value: true }, - { name: 'texture_quality', value: 'detailed' } - ]) - expect(getNodeDisplayPrice(highEndNode)).toBe(creditsLabel(0.5)) - }) - - it('should return fallback for TripoMultiviewToModelNode without widgets', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoMultiviewToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: '(varies with quad, style, texture & quality)' - }) - ) - }) - }) - - describe('Gemini and OpenAI Chat nodes', () => { - it('should return specific pricing for supported Gemini models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - model: 'gemini-2.5-pro-preview-05-06', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-pro', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-3-pro-preview', - expected: creditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-flash-preview-04-17', - expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gemini-2.5-flash', - expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { model: 'unknown-gemini-model', expected: 'Token-based' } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('GeminiNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return fallback for GeminiNode without model widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('GeminiNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return token-based pricing for OpenAIChatNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: 'unknown-model' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return correct pricing for all exposed OpenAI models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - model: 'o4-mini', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-pro', - expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1', - expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3-mini', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3', - expected: creditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4o', - expected: creditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-nano', - expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-mini', - expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1', - expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5-nano', - expected: creditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5-mini', - expected: creditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-5', - expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should handle model ordering correctly (specific before general)', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test that more specific patterns are matched before general ones - const testCases = [ - { - model: 'gpt-4.1-nano-test', - expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-mini-test', - expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'gpt-4.1-test', - expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-pro-test', - expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o1-test', - expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { - model: 'o3-mini-test', - expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - }, - { model: 'unknown-model', expected: 'Token-based' } - ] - - testCases.forEach(({ model, expected }) => { - const node = createMockNode('OpenAIChatNode', [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should return fallback for OpenAIChatNode without model widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('OpenAIChatNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe('Token-based') - }) - - it('should return static price for GeminiImageNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('GeminiImageNode') - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsLabel(0.039, { - approximate: true, - suffix: '/Image (1K)' - }) - ) - }) - }) - - describe('Additional RunwayML edge cases', () => { - it('should handle edge cases for RunwayML duration-based pricing', () => { - const { getNodeDisplayPrice } = useNodePricing() - - // Test edge cases - const RATE_PER_SECOND = 0.0715 - const testCases = [ - { duration: 0, expected: creditsLabel(0) }, - { duration: 1, expected: creditsLabel(RATE_PER_SECOND) }, - { duration: 30, expected: creditsLabel(RATE_PER_SECOND * 30) } - ] - - testCases.forEach(({ duration, expected }) => { - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: duration } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - - it('should handle invalid duration values gracefully', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('RunwayImageToVideoNodeGen3a', [ - { name: 'duration', value: 'invalid-string' } - ]) - // When Number('invalid-string') returns NaN, it falls back to 5 seconds - expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.0715 * 5)) - }) - - it('should handle missing duration widget gracefully', () => { - const { getNodeDisplayPrice } = useNodePricing() - const nodes = [ - 'RunwayImageToVideoNodeGen3a', - 'RunwayImageToVideoNodeGen4', - 'RunwayFirstLastFrameNode' - ] - - nodes.forEach((nodeType) => { - const node = createMockNode(nodeType, []) - expect(getNodeDisplayPrice(node)).toBe( - creditsLabel(0.0715, { suffix: '/second' }) - ) - }) - }) - }) - - describe('Complete Rodin node coverage', () => { - it('should return correct pricing for all Rodin variants', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { nodeType: 'Rodin3D_Regular', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Sketch', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Detail', expected: creditsLabel(0.4) }, - { nodeType: 'Rodin3D_Smooth', expected: creditsLabel(0.4) } - ] - - testCases.forEach(({ nodeType, expected }) => { - const node = createMockNode(nodeType) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) - }) - }) - - describe('Comprehensive Tripo edge case testing', () => { - it('should handle TripoImageToModelNode with various parameter combinations', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - quad: false, - style: 'none', - texture: false, - expected: creditsLabel(0.2) - }, - { - quad: false, - style: 'none', - texture: true, - expected: creditsLabel(0.3) - }, - { - quad: true, - style: 'any style', - texture: true, - textureQuality: 'detailed', - expected: creditsLabel(0.5) - }, - { - quad: false, - style: 'any style', - texture: true, - textureQuality: 'standard', - expected: creditsLabel(0.35) - } - ] - - testCases.forEach( - ({ quad, style, texture, textureQuality, expected }) => { - const widgets = [ - { name: 'model_version', value: 'v3.0' }, - { name: 'quad', value: quad }, - { name: 'style', value: style }, - { name: 'texture', value: texture } - ] - if (textureQuality) { - widgets.push({ name: 'texture_quality', value: textureQuality }) + // Create a node with autogrow-style inputs (group.input1, group.input2, etc.) + const node = createMockNode( + { + name: 'TestInputGroupNode', + api_node: true, + price_badge: { + engine: 'jsonata', + expr: '{"type":"usd","usd": inputGroups.videos * 0.05}', + depends_on: { + widgets: [], + inputs: [], + input_groups: ['videos'] } - const node = createMockNode('TripoImageToModelNode', widgets) - expect(getNodeDisplayPrice(node)).toBe(expected) - } - ) - }) - - it('should return correct fallback for TripoImageToModelNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoImageToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) - }) - - it('should handle missing texture quality widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', []) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) - }) - - it('should handle missing model version widget', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('TripoTextToModelNode', [ - { name: 'texture_quality', value: 'detailed' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsRangeLabel(0.1, 0.65, { - note: 'varies with quad, style, texture & quality' - }) - ) - }) - - it('should return correct pricing for exposed ByteDance models', () => { - const { getNodeDisplayPrice } = useNodePricing() - - const testCases = [ - { - node_name: 'ByteDanceImageNode', - model: 'seedream-3-0-t2i-250415', - expected: creditsLabel(0.03) - }, - { - node_name: 'ByteDanceImageEditNode', - model: 'seededit-3-0-i2i-250628', - expected: creditsLabel(0.03) } + }, + [], + [ + { name: 'videos.clip1', link: 1 }, // connected + { name: 'videos.clip2', link: 2 }, // connected + { name: 'videos.clip3', link: null }, // disconnected + { name: 'other_input', link: 3 } // connected but not in group ] + ) - testCases.forEach(({ node_name, model, expected }) => { - const node = createMockNode(node_name, [ - { name: 'model', value: model } - ]) - expect(getNodeDisplayPrice(node)).toBe(expected) - }) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + // 2 connected inputs in 'videos' group * 0.05 = 0.10 + expect(price).toBe(creditsLabel(0.1)) + }) + }) + + describe('decimal formatting', () => { + describe('shouldShowDecimal helper', () => { + it('should return true when first decimal digit is non-zero', () => { + expect(shouldShowDecimal(10.5)).toBe(true) + expect(shouldShowDecimal(10.1)).toBe(true) + expect(shouldShowDecimal(10.9)).toBe(true) + expect(shouldShowDecimal(1.5)).toBe(true) + }) + + it('should return false for whole numbers', () => { + expect(shouldShowDecimal(10)).toBe(false) + expect(shouldShowDecimal(10.0)).toBe(false) + expect(shouldShowDecimal(1)).toBe(false) + expect(shouldShowDecimal(100)).toBe(false) + }) + + it('should return false when decimal rounds to zero', () => { + // 10.04 rounds to 10.0, so no decimal shown + expect(shouldShowDecimal(10.04)).toBe(false) + expect(shouldShowDecimal(10.049)).toBe(false) + }) + + it('should return true when decimal rounds to non-zero', () => { + // 10.05 rounds to 10.1, so decimal shown + expect(shouldShowDecimal(10.05)).toBe(true) + expect(shouldShowDecimal(10.06)).toBe(true) + // 10.45 rounds to 10.5 + expect(shouldShowDecimal(10.45)).toBe(true) }) }) - }) - describe('dynamic pricing - ByteDanceSeedreamNode', () => { - it('should return $0.03 x images/Run', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceSeedreamNode', [ - { name: 'model', value: 'seedream-4-0-250828' } - ]) + describe('credit value formatting', () => { + it('should show decimal for USD values that result in fractional credits', () => { + // $0.05 * 211 = 10.55 credits → "10.6" + const value1 = creditValue(0.05) + expect(value1).toBe('10.6') - const price = getNodeDisplayPrice(node) - expect(price).toBe( - creditsLabel(0.03, { - suffix: ' x images/Run', - approximate: true - }) - ) - }) - }) + // $0.10 * 211 = 21.1 credits → "21.1" + const value2 = creditValue(0.1) + expect(value2).toBe('21.1') + }) - describe('dynamic pricing - ByteDance Seedance video nodes', () => { - it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) + it('should not show decimal for USD values that result in whole credits', () => { + // $1.00 * 211 = 211 credits → "211" + const value = creditValue(1.0) + expect(value).toBe('211') + }) - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(1.18, 1.22)) + it('should not show decimal when credits round to whole number', () => { + // Find a USD value that results in credits close to a whole number + // $0.0473933... * 211 ≈ 10.0 credits + // Let's use a value that gives us ~10.02 credits which rounds to 10.0 + const usd = 10.02 / CREDITS_PER_USD // ~0.0475 USD → ~10.02 credits + const value = creditValue(usd) + expect(value).toBe('10') + }) }) - it('should scale to half for 5s PRO 1080p on ByteDanceTextToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '5' }, - { name: 'resolution', value: '1080p' } - ]) + describe('integration with pricing display', () => { + it('should display decimal in badge for fractional credits', async () => { + const { getNodeDisplayPrice } = useNodePricing() + // $0.05 * 211 = 10.55 credits → "10.6 credits/Run" + const node = createMockNodeWithPriceBadge( + 'TestDecimalNode', + priceBadge('{"type":"usd","usd":0.05}') + ) - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.59, 0.61)) - }) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('10.6 credits/Run') + }) - it('should scale for 8s PRO 480p on ByteDanceImageToVideoNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceImageToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '8' }, - { name: 'resolution', value: '480p' } - ]) + it('should not display decimal in badge for whole credits', async () => { + const { getNodeDisplayPrice } = useNodePricing() + // $1.00 * 211 = 211 credits → "211 credits/Run" + const node = createMockNodeWithPriceBadge( + 'TestWholeCreditsNode', + priceBadge('{"type":"usd","usd":1.00}') + ) - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.23 * 0.8, 0.24 * 0.8)) - }) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('211 credits/Run') + }) - it('should scale correctly for 12s PRO 720p on ByteDanceFirstLastFrameNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceFirstLastFrameNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '720p' } - ]) + it('should handle range with mixed decimal display', async () => { + const { getNodeDisplayPrice } = useNodePricing() + // min: $0.05 * 211 = 10.55 → 10.6 + // max: $1.00 * 211 = 211 → 211 + const node = createMockNodeWithPriceBadge( + 'TestMixedRangeNode', + priceBadge('{"type":"range_usd","min_usd":0.05,"max_usd":1.00}') + ) - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.51, 0.56)) - }) - - it('should collapse to a single value when min and max round equal for LITE 480p 3s on ByteDanceImageReferenceNode', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('ByteDanceImageReferenceNode', [ - { name: 'model', value: 'seedance-1-0-lite' }, - { name: 'duration', value: '3' }, - { name: 'resolution', value: '480p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.05)) // 0.17..0.18 scaled by 0.3 both round to 0.05 - }) - - it('should return Token-based when required widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingModel = createMockNode('ByteDanceFirstLastFrameNode', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) - const missingResolution = createMockNode('ByteDanceImageToVideoNode', [ - { name: 'model', value: 'seedance-1-0-pro' }, - { name: 'duration', value: '10' } - ]) - const missingDuration = createMockNode('ByteDanceTextToVideoNode', [ - { name: 'model', value: 'seedance-1-0-lite' }, - { name: 'resolution', value: '720p' } - ]) - - expect(getNodeDisplayPrice(missingModel)).toBe('Token-based') - expect(getNodeDisplayPrice(missingResolution)).toBe('Token-based') - expect(getNodeDisplayPrice(missingDuration)).toBe('Token-based') - }) - }) - - describe('dynamic pricing - WanTextToVideoApi', () => { - it('should return $1.50 for 10s at 1080p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'size', value: '1080p: 4:3 (1632x1248)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 - }) - - it('should return $0.50 for 5s at 720p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: 5 }, - { name: 'size', value: '720p: 16:9 (1280x720)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.5)) // 0.10 * 5 - }) - - it('should return $0.15 for 3s at 480p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '3' }, - { name: 'size', value: '480p: 1:1 (624x624)' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.15)) // 0.05 * 3 - }) - - it('should fall back when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingBoth = createMockNode('WanTextToVideoApi', []) - const missingSize = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '5' } - ]) - const missingDuration = createMockNode('WanTextToVideoApi', [ - { name: 'size', value: '1080p' } - ]) - - expect(getNodeDisplayPrice(missingBoth)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingSize)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingDuration)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - }) - - it('should fall back on invalid duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: 'invalid' }, - { name: 'size', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - - it('should fall back on unknown resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanTextToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'size', value: '2K' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - }) - - describe('dynamic pricing - WanImageToVideoApi', () => { - it('should return $0.80 for 8s at 720p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: 8 }, - { name: 'resolution', value: '720p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.8)) // 0.10 * 8 - }) - - it('should return $0.60 for 12s at 480P', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '12' }, - { name: 'resolution', value: '480P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.6)) // 0.05 * 12 - }) - - it('should return $1.50 for 10s at 1080p', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: '1080p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.5)) // 0.15 * 10 - }) - - it('should handle "5s" string duration at 1080P', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '5s' }, - { name: 'resolution', value: '1080P' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.75)) // 0.15 * 5 - }) - - it('should fall back when widgets are missing', () => { - const { getNodeDisplayPrice } = useNodePricing() - const missingBoth = createMockNode('WanImageToVideoApi', []) - const missingRes = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '5' } - ]) - const missingDuration = createMockNode('WanImageToVideoApi', [ - { name: 'resolution', value: '1080p' } - ]) - - expect(getNodeDisplayPrice(missingBoth)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingRes)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - expect(getNodeDisplayPrice(missingDuration)).toBe( - creditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - ) - }) - - it('should fall back on invalid duration', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: 'invalid' }, - { name: 'resolution', value: '720p' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - - it('should fall back on unknown resolution', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('WanImageToVideoApi', [ - { name: 'duration', value: '10' }, - { name: 'resolution', value: 'weird-res' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.05, 0.15, { suffix: '/second' })) - }) - }) - - describe('dynamic pricing - LtxvApiTextToVideo', () => { - it('should return $0.30 for Pro 1080p 5s', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Pro)' }, - { name: 'duration', value: '5' }, - { name: 'resolution', value: '1920x1080' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.3)) // 0.06 * 5 - }) - - it('should parse "10s" duration strings', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Fast)' }, - { name: 'duration', value: '10' }, - { name: 'resolution', value: '3840x2160' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(1.6)) // 0.16 * 10 - }) - - it('should fall back when a required widget is missing (no resolution)', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-2 (Pro)' }, - { name: 'duration', value: '5' } - // missing resolution - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.04, 0.24, { suffix: '/second' })) - }) - - it('should fall back for unknown model', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('LtxvApiTextToVideo', [ - { name: 'model', value: 'LTX-3 (Pro)' }, - { name: 'duration', value: 5 }, - { name: 'resolution', value: '1920x1080' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsRangeLabel(0.04, 0.24, { suffix: '/second' })) + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const price = getNodeDisplayPrice(node) + expect(price).toBe('10.6-211 credits/Run') + }) }) }) }) -const CREDIT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { - minimumFractionDigits: 0, - maximumFractionDigits: 0 -} - -type CreditFormatOptions = { - suffix?: string - note?: string - approximate?: boolean -} - -const creditValue = (usd: number): string => - formatCreditsFromUsd({ - usd, - numberOptions: CREDIT_NUMBER_OPTIONS - }) - -const prefix = (approximate?: boolean) => (approximate ? '~' : '') -const suffix = (value?: string) => value ?? '/Run' -const note = (value?: string) => { - if (!value) return '' - const trimmed = value.trim() - const hasParens = trimmed.startsWith('(') && trimmed.endsWith(')') - const content = hasParens ? trimmed : `(${trimmed})` - return ` ${content}` -} - -const creditsLabel = ( - usd: number, - { - suffix: suffixOverride, - note: noteOverride, - approximate - }: CreditFormatOptions = {} -): string => - `${prefix(approximate)}${creditValue(usd)} credits${suffix(suffixOverride)}${note(noteOverride)}` - -const creditsRangeLabel = ( - minUsd: number, - maxUsd: number, - options?: CreditFormatOptions -): string => { - const min = creditValue(minUsd) - const max = creditValue(maxUsd) - const value = min === max ? min : `${min}-${max}` - return `${prefix(options?.approximate)}${value} credits${suffix(options?.suffix)}${note(options?.note)}` -} - -const creditsListLabel = ( - usdValues: number[], - options?: CreditFormatOptions & { separator?: string } -): string => { - const parts = usdValues.map((value) => creditValue(value)) - const value = parts.join(options?.separator ?? '/') - return `${prefix(options?.approximate)}${value} credits${suffix(options?.suffix)}${note(options?.note)}` -} diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 95c432747..5d08e5794 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1,23 +1,45 @@ -import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' -import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +// JSONata-based pricing badge evaluation for API nodes. +// +// Pricing declarations are read from ComfyUI node definitions (price_badge field). +// The Frontend evaluates these declarations locally using a JSONata engine. +// +// JSONata v2.x NOTE: +// - jsonata(expression).evaluate(input) returns a Promise in JSONata 2.x. +// - Therefore, pricing evaluation is async. This file implements: +// - sync getter (returns cached label / last-known label), +// - async evaluation + cache, +// - reactive tick to update UI when async evaluation completes. + +import { readonly, ref } from 'vue' +import type { Ref } from 'vue' +import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { + ComfyNodeDef, + PriceBadge, + WidgetDependency +} from '@/schemas/nodeDefSchema' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import type { Expression } from 'jsonata' +import jsonata from 'jsonata' /** - * Meshy credit pricing constant. - * 1 Meshy credit = $0.04 USD - * Change this value to update all Meshy node prices. + * Determine if a number should display 1 decimal place. + * Shows decimal only when the first decimal digit is non-zero. */ -const MESHY_CREDIT_PRICE_USD = 0.04 - -/** Convert Meshy credits to USD */ -const meshyCreditsToUsd = (credits: number): number => - credits * MESHY_CREDIT_PRICE_USD - -const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { - minimumFractionDigits: 0, - maximumFractionDigits: 0 +const shouldShowDecimal = (value: number): boolean => { + const rounded = Math.round(value * 10) / 10 + return rounded % 1 !== 0 } +const getNumberOptions = (credits: number): Intl.NumberFormatOptions => ({ + minimumFractionDigits: 0, + maximumFractionDigits: shouldShowDecimal(credits) ? 1 : 0 +}) + type CreditFormatOptions = { suffix?: string note?: string @@ -25,11 +47,14 @@ type CreditFormatOptions = { separator?: string } -const formatCreditsValue = (usd: number): string => - formatCreditsFromUsd({ - usd, - numberOptions: DEFAULT_NUMBER_OPTIONS +const formatCreditsValue = (usd: number): string => { + // Use raw credits value (before rounding) to determine decimal display + const rawCredits = usd * CREDITS_PER_USD + return formatCredits({ + value: rawCredits, + numberOptions: getNumberOptions(rawCredits) }) +} const makePrefix = (approximate?: boolean) => (approximate ? '~' : '') @@ -63,2616 +88,567 @@ const formatCreditsListLabel = ( return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}` } -/** - * Function that calculates dynamic pricing based on node widget values - */ -type PricingFunction = (node: LGraphNode) => string - -/** - * Safely executes a pricing function with error handling - * Returns null if the function throws an error, allowing the node to still render - */ -function safePricingExecution( - fn: PricingFunction, - node: LGraphNode, - fallback: string = '' -): string { - try { - return fn(node) - } catch (error) { - // Log error in development but don't throw to avoid breaking node rendering - if (process.env.NODE_ENV === 'development') { - console.warn( - 'Pricing calculation failed for node:', - node.constructor?.nodeData?.name, - error - ) +// ----------------------------- +// JSONata pricing types +// ----------------------------- +type PricingResult = + | { type: 'text'; text: string } + | { type: 'usd'; usd: number; format?: CreditFormatOptions } + | { + type: 'range_usd' + min_usd: number + max_usd: number + format?: CreditFormatOptions } - return fallback - } -} + | { type: 'list_usd'; usd: number[]; format?: CreditFormatOptions } -/** - * Helper function to calculate Runway duration-based pricing - * @param node - The LiteGraph node - * @returns Formatted price string - */ -const calculateRunwayDurationPrice = (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget +const PRICING_RESULT_TYPES = ['text', 'usd', 'range_usd', 'list_usd'] as const - if (!durationWidget) return formatCreditsLabel(0.0715, { suffix: '/second' }) - - const duration = Number(durationWidget.value) - const validDuration = isNaN(duration) ? 5 : duration - const cost = 0.0715 * validDuration - return formatCreditsLabel(cost) -} - -const makeOmniProDurationCalculator = - (pricePerSecond: number): PricingFunction => - (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - if (!durationWidget) - return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - if (!Number.isFinite(seconds)) - return formatCreditsLabel(pricePerSecond, { suffix: '/second' }) - - const cost = pricePerSecond * seconds - return formatCreditsLabel(cost) - } - -const klingMotionControlPricingCalculator: PricingFunction = ( - node: LGraphNode -): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - - if (!modeWidget) { - return formatCreditsListLabel([0.07, 0.112], { - suffix: '/second', - note: '(std/pro)' - }) - } - - const mode = String(modeWidget.value).toLowerCase() - - if (mode === 'pro') return formatCreditsLabel(0.112, { suffix: '/second' }) - if (mode === 'std') return formatCreditsLabel(0.07, { suffix: '/second' }) - - return formatCreditsListLabel([0.07, 0.112], { - suffix: '/second', - note: '(std/pro)' - }) -} - -const pixversePricingCalculator = (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration_seconds' - ) as IComboWidget - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - const motionModeWidget = node.widgets?.find( - (w) => w.name === 'motion_mode' - ) as IComboWidget - - if (!durationWidget || !qualityWidget) { - return formatCreditsRangeLabel(0.45, 1.2, { - note: '(varies with duration, quality & motion mode)' - }) - } - - const duration = String(durationWidget.value) - const quality = String(qualityWidget.value) - const motionMode = String(motionModeWidget?.value) - - // Basic pricing based on duration and quality - if (duration.includes('5')) { - if (quality.includes('1080p')) return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.6) - if (quality.includes('540p') && motionMode?.includes('fast')) - return formatCreditsLabel(0.9) - if (quality.includes('540p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.45) - if (quality.includes('360p') && motionMode?.includes('fast')) - return formatCreditsLabel(0.9) - if (quality.includes('360p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.45) - } else if (duration.includes('8')) { - if (quality.includes('540p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.9) - if (quality.includes('540p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('360p') && motionMode?.includes('normal')) - return formatCreditsLabel(0.9) - if (quality.includes('360p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('1080p') && motionMode?.includes('normal')) - return formatCreditsLabel(1.2) - if (quality.includes('1080p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('normal')) - return formatCreditsLabel(1.2) - if (quality.includes('720p') && motionMode?.includes('fast')) - return formatCreditsLabel(1.2) - } - - return formatCreditsLabel(0.9) -} - -const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget | undefined - - if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based' - - const model = String(modelWidget.value).toLowerCase() - const resolution = String(resolutionWidget.value).toLowerCase() - const seconds = parseFloat(String(durationWidget.value)) - const generateAudio = - generateAudioWidget && - String(generateAudioWidget.value).toLowerCase() === 'true' - const priceByModel: Record> = { - 'seedance-1-5-pro': { - '480p': [0.12, 0.12], - '720p': [0.26, 0.26], - '1080p': [0.58, 0.59] - }, - 'seedance-1-0-pro': { - '480p': [0.23, 0.24], - '720p': [0.51, 0.56], - '1080p': [1.18, 1.22] - }, - 'seedance-1-0-pro-fast': { - '480p': [0.09, 0.1], - '720p': [0.21, 0.23], - '1080p': [0.47, 0.49] - }, - 'seedance-1-0-lite': { - '480p': [0.17, 0.18], - '720p': [0.37, 0.41], - '1080p': [0.85, 0.88] - } - } - - const modelKey = model.includes('seedance-1-5-pro') - ? 'seedance-1-5-pro' - : model.includes('seedance-1-0-pro-fast') - ? 'seedance-1-0-pro-fast' - : model.includes('seedance-1-0-pro') - ? 'seedance-1-0-pro' - : model.includes('seedance-1-0-lite') - ? 'seedance-1-0-lite' - : '' - - const resKey = resolution.includes('1080') - ? '1080p' - : resolution.includes('720') - ? '720p' - : resolution.includes('480') - ? '480p' - : '' - - const baseRange = - modelKey && resKey ? priceByModel[modelKey]?.[resKey] : undefined - if (!baseRange) return 'Token-based' - - const [min10s, max10s] = baseRange - const scale = seconds / 10 - const audioMultiplier = - modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1 - const minCost = min10s * scale * audioMultiplier - const maxCost = max10s * scale * audioMultiplier - - if (minCost === maxCost) return formatCreditsLabel(minCost) - return formatCreditsRangeLabel(minCost, maxCost) -} - -const ltxvPricingCalculator = (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - const fallback = formatCreditsRangeLabel(0.04, 0.24, { - suffix: '/second' - }) - if (!modelWidget || !durationWidget || !resolutionWidget) return fallback - - const model = String(modelWidget.value).toLowerCase() - const resolution = String(resolutionWidget.value).toLowerCase() - const seconds = parseFloat(String(durationWidget.value)) - const priceByModel: Record> = { - 'ltx-2 (pro)': { - '1920x1080': 0.06, - '2560x1440': 0.12, - '3840x2160': 0.24 - }, - 'ltx-2 (fast)': { - '1920x1080': 0.04, - '2560x1440': 0.08, - '3840x2160': 0.16 - } - } - - const modelTable = priceByModel[model] - if (!modelTable) return fallback - - const pps = modelTable[resolution] - if (!pps) return fallback - - const cost = pps * seconds - return formatCreditsLabel(cost) -} - -const klingVideoWithAudioPricingCalculator: PricingFunction = ( - node: LGraphNode -): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - - if (!durationWidget || !generateAudioWidget) { - return formatCreditsRangeLabel(0.35, 1.4, { - note: '(varies with duration & audio)' - }) - } - - const duration = String(durationWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - - if (duration === '5') { - return generateAudio ? formatCreditsLabel(0.7) : formatCreditsLabel(0.35) - } - - if (duration === '10') { - return generateAudio ? formatCreditsLabel(1.4) : formatCreditsLabel(0.7) - } - - // Fallback for unexpected duration values - return formatCreditsRangeLabel(0.35, 1.4, { - note: '(varies with duration & audio)' - }) -} - -// ---- constants ---- -const SORA_SIZES = { - BASIC: new Set(['720x1280', '1280x720']), - PRO: new Set(['1024x1792', '1792x1024']) -} -const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO]) - -// ---- sora-2 pricing helpers ---- -function validateSora2Selection( - modelRaw: string, - duration: number, - sizeRaw: string -): string | undefined { - const model = modelRaw?.toLowerCase() ?? '' - const size = sizeRaw?.toLowerCase() ?? '' - - if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)' - if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)' - if (!ALL_SIZES.has(size)) - return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.' - - if (model.includes('sora-2-pro')) return undefined - - if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size)) - return 'sora-2 supports only 720x1280 or 1280x720' - - if (!model.includes('sora-2')) return 'Unsupported model' - - return undefined -} - -function perSecForSora2(modelRaw: string, sizeRaw: string): number { - const model = modelRaw?.toLowerCase() ?? '' - const size = sizeRaw?.toLowerCase() ?? '' - - if (model.includes('sora-2-pro')) { - return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3 - } - if (model.includes('sora-2')) return 0.1 - - return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1 -} - -function formatRunPrice(perSec: number, duration: number) { - return formatCreditsLabel(Number((perSec * duration).toFixed(2))) -} - -// ---- pricing calculator ---- -const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => { - const getWidgetValue = (name: string) => - String(node.widgets?.find((w) => w.name === name)?.value ?? '') - - const model = getWidgetValue('model') - const size = getWidgetValue('size') - const duration = Number( - node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name)) - ?.value +/** Type guard to validate that a value is a PricingResult. */ +const isPricingResult = (value: unknown): value is PricingResult => + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as { type: unknown }).type === 'string' && + PRICING_RESULT_TYPES.includes( + (value as { type: string }).type as (typeof PRICING_RESULT_TYPES)[number] ) - if (!model || !size || !duration) return 'Set model, duration & size' +/** + * Widget values are normalized based on their declared type: + * - INT/FLOAT → number (or null if not parseable) + * - BOOLEAN → boolean (or null if not parseable) + * - STRING/COMBO/other → string (lowercased, trimmed) + */ +type NormalizedWidgetValue = string | number | boolean | null - const validationError = validateSora2Selection(model, duration, size) - if (validationError) return validationError +type JsonataPricingRule = { + engine: 'jsonata' + depends_on: { + widgets: WidgetDependency[] + inputs: string[] + input_groups: string[] + } + expr: string + result_defaults?: CreditFormatOptions +} - const perSec = perSecForSora2(model, size) - return formatRunPrice(perSec, duration) +type CompiledJsonataPricingRule = JsonataPricingRule & { + _compiled: Expression | null } /** - * Pricing for Tripo 3D generation nodes (Text / Image / Multiview) - * based on Tripo credits: - * - * Turbo / V3 / V2.5 / V2.0: - * Text -> 10 (no texture) / 20 (standard texture) - * Image -> 20 (no texture) / 30 (standard texture) - * Multiview -> 20 (no texture) / 30 (standard texture) - * - * V1.4: - * Text -> 20 - * Image -> 30 - * (Multiview treated same as Image if used) - * - * Advanced extras (added on top of generation credits): - * quad -> +5 credits - * style -> +5 credits (if style != "None") - * HD texture -> +10 credits (texture_quality = "detailed") - * detailed geometry -> +20 credits (geometry_quality = "detailed") - * - * 1 credit = $0.01 + * Shape of nodeData attached to LGraphNode constructor for API nodes. + * Uses Pick from schema type to ensure consistency. */ -const calculateTripo3DGenerationPrice = ( +type NodeConstructorData = Partial< + Pick +> + +/** + * Extract nodeData from an LGraphNode's constructor. + * Centralizes the `as any` cast needed to access this runtime property. + */ +const getNodeConstructorData = ( + node: LGraphNode +): NodeConstructorData | undefined => + (node.constructor as { nodeData?: NodeConstructorData }).nodeData + +type JsonataEvalContext = { + widgets: Record + inputs: Record + /** Count of connected inputs per autogrow group */ + inputGroups: Record +} + +// ----------------------------- +// Normalization helpers +// ----------------------------- +const asFiniteNumber = (v: unknown): number | null => { + if (v === null || v === undefined) return null + + if (typeof v === 'number') return Number.isFinite(v) ? v : null + + if (typeof v === 'string') { + const t = v.trim() + if (t === '') return null + const n = Number(t) + return Number.isFinite(n) ? n : null + } + + // Do not coerce booleans/objects into numbers for pricing purposes. + return null +} + +/** + * Normalize widget value based on its declared type. + * Returns the value in its natural type for simpler JSONata expressions. + */ +const normalizeWidgetValue = ( + raw: unknown, + declaredType: string +): NormalizedWidgetValue => { + if (raw === undefined || raw === null) { + return null + } + + const upperType = declaredType.toUpperCase() + + // Numeric types + if (upperType === 'INT' || upperType === 'FLOAT') { + return asFiniteNumber(raw) + } + + // Boolean type + if (upperType === 'BOOLEAN') { + if (typeof raw === 'boolean') return raw + if (typeof raw === 'string') { + const ls = raw.trim().toLowerCase() + if (ls === 'true') return true + if (ls === 'false') return false + } + return null + } + + // COMBO type - preserve string/numeric values (for options like [5, "10"]) + if (upperType === 'COMBO') { + if (typeof raw === 'number') return raw + if (typeof raw === 'boolean') return raw + return String(raw).trim().toLowerCase() + } + + // String/other types - return as lowercase trimmed string + return String(raw).trim().toLowerCase() +} + +const buildJsonataContext = ( node: LGraphNode, - task: 'text' | 'image' | 'multiview' + rule: JsonataPricingRule +): JsonataEvalContext => { + const widgets: Record = {} + for (const dep of rule.depends_on.widgets) { + const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name) + widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type) + } + + const inputs: Record = {} + for (const name of rule.depends_on.inputs) { + const slot = node.inputs?.find((x: INodeInputSlot) => x.name === name) + inputs[name] = { connected: slot?.link != null } + } + + // Count connected inputs per autogrow group + const inputGroups: Record = {} + for (const groupName of rule.depends_on.input_groups) { + const prefix = groupName + '.' + inputGroups[groupName] = + node.inputs?.filter( + (inp: INodeInputSlot) => + inp.name?.startsWith(prefix) && inp.link != null + ).length ?? 0 + } + + return { widgets, inputs, inputGroups } +} + +const safeValueForSig = (v: unknown): string => { + if (v === null || v === undefined) return '' + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') + return String(v) + try { + return JSON.stringify(v) + } catch { + return String(v) + } +} + +// Signature determines whether we need to re-evaluate when widgets/inputs change. +const buildSignature = ( + ctx: JsonataEvalContext, + rule: JsonataPricingRule ): string => { - const getWidget = (name: string): IComboWidget | undefined => - node.widgets?.find((w) => w.name === name) as IComboWidget | undefined + const parts: string[] = [] + for (const dep of rule.depends_on.widgets) { + parts.push(`w:${dep.name}=${safeValueForSig(ctx.widgets[dep.name])}`) + } + for (const name of rule.depends_on.inputs) { + parts.push(`i:${name}=${ctx.inputs[name]?.connected ? '1' : '0'}`) + } + for (const name of rule.depends_on.input_groups) { + parts.push(`g:${name}=${ctx.inputGroups[name] ?? 0}`) + } + return parts.join('|') +} - const getString = (name: string, defaultValue: string): string => { - const widget = getWidget(name) - if (!widget || widget.value === undefined || widget.value === null) { - return defaultValue +// ----------------------------- +// Result formatting +// ----------------------------- +const formatPricingResult = ( + result: unknown, + defaults: CreditFormatOptions = {} +): string => { + if (!isPricingResult(result)) { + if (result !== undefined && result !== null) { + console.warn('[pricing/jsonata] invalid result format:', result) } - return String(widget.value) + return '' } - const getBool = (name: string, defaultValue: boolean): boolean => { - const widget = getWidget(name) - if (!widget || widget.value === undefined || widget.value === null) { - return defaultValue - } - - const v = widget.value - if (typeof v === 'number') return v !== 0 - const lower = String(v).toLowerCase() - if (lower === 'true') return true - if (lower === 'false') return false - - return defaultValue + if (result.type === 'text') { + return result.text ?? '' } - // ---- read widget values with sensible defaults (mirroring backend) ---- - const modelVersionRaw = getString('model_version', '').toLowerCase() - if (modelVersionRaw === '') - return formatCreditsRangeLabel(0.1, 0.65, { - note: '(varies with quad, style, texture & quality)' + if (result.type === 'usd') { + const usd = asFiniteNumber(result.usd) + if (usd === null) return '' + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsLabel(usd, fmt) + } + + if (result.type === 'range_usd') { + const minUsd = asFiniteNumber(result.min_usd) + const maxUsd = asFiniteNumber(result.max_usd) + if (minUsd === null || maxUsd === null) return '' + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsRangeLabel(minUsd, maxUsd, fmt) + } + + if (result.type === 'list_usd') { + const arr = Array.isArray(result.usd) ? result.usd : null + if (!arr) return '' + + const usdValues = arr + .map(asFiniteNumber) + .filter((x): x is number => x != null) + + if (usdValues.length === 0) return '' + + const fmt = { ...defaults, ...(result.format ?? {}) } + return formatCreditsListLabel(usdValues, fmt) + } + + return '' +} + +// ----------------------------- +// Compile rules (non-fatal) +// ----------------------------- +const compileRule = (rule: JsonataPricingRule): CompiledJsonataPricingRule => { + try { + return { ...rule, _compiled: jsonata(rule.expr) } + } catch (e) { + // Do not crash app on bad expressions; just disable rule. + console.error('[pricing/jsonata] failed to compile expr:', rule.expr, e) + return { ...rule, _compiled: null } + } +} + +// ----------------------------- +// Rule cache (per-node-type) +// ----------------------------- +// Cache compiled rules by node type name to avoid recompiling on every evaluation. +const compiledRulesCache = new Map() + +/** + * Convert a PriceBadge from node definition to a JsonataPricingRule. + */ +const priceBadgeToRule = (priceBadge: PriceBadge): JsonataPricingRule => ({ + engine: priceBadge.engine ?? 'jsonata', + depends_on: { + widgets: priceBadge.depends_on?.widgets ?? [], + inputs: priceBadge.depends_on?.inputs ?? [], + input_groups: priceBadge.depends_on?.input_groups ?? [] + }, + expr: priceBadge.expr +}) + +/** + * Get or compile a pricing rule for a node type. + */ +const getCompiledRuleForNodeType = ( + nodeName: string, + priceBadge: PriceBadge | undefined +): CompiledJsonataPricingRule | null => { + if (!priceBadge) return null + + // Check cache first + if (compiledRulesCache.has(nodeName)) { + return compiledRulesCache.get(nodeName) ?? null + } + + // Compile and cache + const rule = priceBadgeToRule(priceBadge) + const compiled = compileRule(rule) + compiledRulesCache.set(nodeName, compiled) + return compiled +} + +// ----------------------------- +// Async evaluation + cache (JSONata 2.x) +// ----------------------------- + +// Reactive tick to force UI updates when async evaluations resolve. +// We purposely read pricingTick.value inside getNodeDisplayPrice to create a dependency. +const pricingTick = ref(0) + +// Per-node revision tracking for VueNodes mode (more efficient than global tick) +// Uses plain Map with individual refs per node for fine-grained reactivity +// Keys are stringified node IDs to handle both string and number ID types +const nodeRevisions = new Map>() + +/** + * Get or create a revision ref for a specific node. + * Each node has its own independent ref, so updates to one won't trigger others. + */ +const getNodeRevisionRef = (nodeId: string | number): Ref => { + const key = String(nodeId) + let rev = nodeRevisions.get(key) + if (!rev) { + rev = ref(0) + nodeRevisions.set(key, rev) + } + return rev +} + +// WeakMaps avoid memory leaks when nodes are removed. +type CacheEntry = { sig: string; label: string } +type InflightEntry = { sig: string; promise: Promise } + +const cache = new WeakMap() +const desiredSig = new WeakMap() +const inflight = new WeakMap() + +const DEBUG_JSONATA_PRICING = false + +const scheduleEvaluation = ( + node: LGraphNode, + rule: CompiledJsonataPricingRule, + ctx: JsonataEvalContext, + sig: string +) => { + desiredSig.set(node, sig) + + const running = inflight.get(node) + if (running && running.sig === sig) return + + if (!rule._compiled) return + + const nodeName = getNodeConstructorData(node)?.name ?? '' + + const promise = Promise.resolve(rule._compiled.evaluate(ctx)) + .then((res) => { + const label = formatPricingResult(res, rule.result_defaults ?? {}) + + // Ignore stale results: if the node changed while we were evaluating, + // desiredSig will no longer match. + if (desiredSig.get(node) !== sig) return + + cache.set(node, { sig, label }) + + if (DEBUG_JSONATA_PRICING) { + console.warn('[pricing/jsonata] resolved', nodeName, { + sig, + res, + label + }) + } }) - const styleRaw = getString('style', 'None') - const hasStyle = styleRaw.toLowerCase() !== 'none' + .catch((err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[pricing/jsonata] evaluation failed', nodeName, err) + } - // Backend defaults: texture=true, pbr=true, quad=false, qualities="standard" - const hasTexture = getBool('texture', false) - const hasPbr = getBool('pbr', false) - const quad = getBool('quad', false) + // Cache empty to avoid retry-spam for same signature + if (desiredSig.get(node) === sig) { + cache.set(node, { sig, label: '' }) + } + }) + .finally(() => { + const cur = inflight.get(node) + if (cur && cur.sig === sig) inflight.delete(node) - const textureQualityRaw = getString( - 'texture_quality', - 'standard' - ).toLowerCase() - const geometryQualityRaw = getString( - 'geometry_quality', - 'standard' - ).toLowerCase() - - const isHdTexture = textureQualityRaw === 'detailed' - const isDetailedGeometry = geometryQualityRaw === 'detailed' - - const withTexture = hasTexture || hasPbr - - let baseCredits: number - - if (modelVersionRaw.includes('v1.4')) { - // V1.4 model: Text=20, Image=30, Refine=30 - if (task === 'text') { - baseCredits = 20 - } else { - // treat Multiview same as Image if V1.4 is ever used there - baseCredits = 30 - } - } else { - // V3.0, V2.5, V2.0 models - if (!withTexture) { - if (task === 'text') { - baseCredits = 10 // Text to 3D without texture + if (LiteGraph.vueNodesMode) { + // VueNodes mode: bump per-node revision (only this node re-renders) + getNodeRevisionRef(node.id).value++ } else { - baseCredits = 20 // Image/Multiview to 3D without texture + // Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas + pricingTick.value++ } - } else { - if (task === 'text') { - baseCredits = 20 // Text to 3D with standard texture - } else { - baseCredits = 30 // Image/Multiview to 3D with standard texture - } - } - } + }) - // ---- advanced extras on top of base generation ---- - let credits = baseCredits - - if (hasStyle) credits += 5 // Style - if (quad) credits += 5 // Quad Topology - if (isHdTexture) credits += 10 // HD Texture - if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality - - const dollars = credits * 0.01 - return formatCreditsLabel(dollars) + inflight.set(node, { sig, promise }) } /** - * Meshy Image to 3D pricing calculator. - * Pricing based on should_texture widget: - * - Without texture: 20 credits - * - With texture: 30 credits + * Get the pricing rule for a node from its nodeData.price_badge field. */ -const calculateMeshyImageToModelPrice = (node: LGraphNode): string => { - const shouldTextureWidget = node.widgets?.find( - (w) => w.name === 'should_texture' - ) as IComboWidget +const getRuleForNode = ( + node: LGraphNode +): CompiledJsonataPricingRule | undefined => { + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return undefined - if (!shouldTextureWidget) { - return formatCreditsRangeLabel( - meshyCreditsToUsd(20), - meshyCreditsToUsd(30), - { note: '(varies with texture)' } - ) - } + const nodeName = nodeData?.name ?? '' + const priceBadge = nodeData?.price_badge - const shouldTexture = String(shouldTextureWidget.value).toLowerCase() - const credits = shouldTexture === 'true' ? 30 : 20 - return formatCreditsLabel(meshyCreditsToUsd(credits)) + if (!priceBadge) return undefined + + const compiled = getCompiledRuleForNodeType(nodeName, priceBadge) + return compiled ?? undefined } -/** - * Meshy Multi-Image to 3D pricing calculator. - * Pricing based on should_texture widget: - * - Without texture: 5 credits - * - With texture: 15 credits - */ -const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => { - const shouldTextureWidget = node.widgets?.find( - (w) => w.name === 'should_texture' - ) as IComboWidget - - if (!shouldTextureWidget) { - return formatCreditsRangeLabel( - meshyCreditsToUsd(5), - meshyCreditsToUsd(15), - { note: '(varies with texture)' } - ) - } - - const shouldTexture = String(shouldTextureWidget.value).toLowerCase() - const credits = shouldTexture === 'true' ? 15 : 5 - return formatCreditsLabel(meshyCreditsToUsd(credits)) -} - -/** - * Static pricing data for API nodes, now supporting both strings and functions - */ -const apiNodeCosts: Record = - { - FluxProCannyNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProDepthNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProExpandNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProFillNode: { - displayPrice: formatCreditsLabel(0.05) - }, - FluxProUltraImageNode: { - displayPrice: formatCreditsLabel(0.06) - }, - FluxProKontextProNode: { - displayPrice: formatCreditsLabel(0.04) - }, - FluxProKontextMaxNode: { - displayPrice: formatCreditsLabel(0.08) - }, - Flux2ProImageNode: { - displayPrice: (node: LGraphNode): string => { - const widthW = node.widgets?.find( - (w) => w.name === 'width' - ) as IComboWidget - const heightW = node.widgets?.find( - (w) => w.name === 'height' - ) as IComboWidget - - const w = Number(widthW?.value) - const h = Number(heightW?.value) - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - // global min/max for this node given schema bounds (1MP..4MP output) - return formatCreditsRangeLabel(0.03, 0.15) - } - - // Is the 'images' input connected? - const imagesInput = node.inputs?.find( - (i) => i.name === 'images' - ) as INodeInputSlot - const hasRefs = - typeof imagesInput?.link !== 'undefined' && imagesInput.link != null - - // Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015 - const MP = 1024 * 1024 - const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP)) - const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0) - - if (hasRefs) { - // Unknown ref count/size on the frontend: - // min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs) - const minTotal = outputCost + 0.015 - const maxTotal = outputCost + 0.12 - return formatCreditsRangeLabel(minTotal, maxTotal, { - approximate: true - }) - } - - // Precise text-to-image price - return formatCreditsLabel(outputCost) - } - }, - Flux2MaxImageNode: { - displayPrice: (node: LGraphNode): string => { - const widthW = node.widgets?.find( - (w) => w.name === 'width' - ) as IComboWidget - const heightW = node.widgets?.find( - (w) => w.name === 'height' - ) as IComboWidget - - const w = Number(widthW?.value) - const h = Number(heightW?.value) - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - // global min/max for this node given schema bounds (1MP..4MP output) - return formatCreditsRangeLabel(0.07, 0.35) - } - - // Is the 'images' input connected? - const imagesInput = node.inputs?.find( - (i) => i.name === 'images' - ) as INodeInputSlot - const hasRefs = - typeof imagesInput?.link !== 'undefined' && imagesInput.link != null - - // Output cost: ceil((w*h)/MP); first MP $0.07, each additional $0.03 - const MP = 1024 * 1024 - const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP)) - const outputCost = 0.07 + 0.03 * Math.max(outMP - 1, 0) - - if (hasRefs) { - // Unknown ref count/size on the frontend: - // min extra is $0.03, max extra is $0.24 (8 MP cap / 8 refs) - const minTotal = outputCost + 0.03 - const maxTotal = outputCost + 0.24 - return formatCreditsRangeLabel(minTotal, maxTotal) - } - - return formatCreditsLabel(outputCost) - } - }, - OpenAIVideoSora2: { - displayPrice: sora2PricingCalculator - }, - IdeogramV1: { - displayPrice: (node: LGraphNode): string => { - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const turboWidget = node.widgets?.find( - (w) => w.name === 'turbo' - ) as IComboWidget - - if (!numImagesWidget) - return formatCreditsRangeLabel(0.03, 0.09, { - suffix: ' x num_images/Run' - }) - - const numImages = Number(numImagesWidget.value) || 1 - const turbo = String(turboWidget?.value).toLowerCase() === 'true' - const basePrice = turbo ? 0.0286 : 0.0858 - const cost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - IdeogramV2: { - displayPrice: (node: LGraphNode): string => { - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const turboWidget = node.widgets?.find( - (w) => w.name === 'turbo' - ) as IComboWidget - - if (!numImagesWidget) - return formatCreditsRangeLabel(0.07, 0.11, { - suffix: ' x num_images/Run' - }) - - const numImages = Number(numImagesWidget.value) || 1 - const turbo = String(turboWidget?.value).toLowerCase() === 'true' - const basePrice = turbo ? 0.0715 : 0.1144 - const cost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - IdeogramV3: { - displayPrice: (node: LGraphNode): string => { - const renderingSpeedWidget = node.widgets?.find( - (w) => w.name === 'rendering_speed' - ) as IComboWidget - const numImagesWidget = node.widgets?.find( - (w) => w.name === 'num_images' - ) as IComboWidget - const characterInput = node.inputs?.find( - (i) => i.name === 'character_image' - ) as INodeInputSlot - const hasCharacter = - typeof characterInput?.link !== 'undefined' && - characterInput.link != null - - if (!renderingSpeedWidget) - return formatCreditsRangeLabel(0.04, 0.11, { - suffix: ' x num_images/Run', - note: '(varies with rendering speed & num_images)' - }) - - const numImages = Number(numImagesWidget?.value) || 1 - let basePrice = 0.0858 // default balanced price - - const renderingSpeed = String(renderingSpeedWidget.value) - if (renderingSpeed.toLowerCase().includes('quality')) { - if (hasCharacter) { - basePrice = 0.286 - } else { - basePrice = 0.1287 - } - } else if (renderingSpeed.toLowerCase().includes('default')) { - if (hasCharacter) { - basePrice = 0.2145 - } else { - basePrice = 0.0858 - } - } else if (renderingSpeed.toLowerCase().includes('turbo')) { - if (hasCharacter) { - basePrice = 0.143 - } else { - basePrice = 0.0429 - } - } - - const totalCost = Number((basePrice * numImages).toFixed(2)) - return formatCreditsLabel(totalCost) - } - }, - KlingCameraControlI2VNode: { - displayPrice: formatCreditsLabel(0.49) - }, - KlingCameraControlT2VNode: { - displayPrice: formatCreditsLabel(0.14) - }, - KlingDualCharacterVideoEffectNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - if (!modeWidget || !modelWidget || !durationWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - const durationValue = String(durationWidget.value) - const modelValue = String(modelWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modelValue.includes('v1')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingImage2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modeWidget) { - if (!modelWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modelValue = String(modelWidget.value) - if ( - modelValue.includes('v2-1-master') || - modelValue.includes('v2-master') - ) { - return formatCreditsLabel(1.4) - } else if ( - modelValue.includes('v1-6') || - modelValue.includes('v1-5') - ) { - return formatCreditsLabel(0.28) - } - return formatCreditsLabel(0.14) - } - - const modeValue = String(modeWidget.value) - const durationValue = String(durationWidget.value) - const modelValue = String(modelWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modelValue.includes('v2-5-turbo')) { - if (durationValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if ( - modelValue.includes('v2-1-master') || - modelValue.includes('v2-master') - ) { - if (durationValue.includes('10')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if ( - modelValue.includes('v2-1') || - modelValue.includes('v1-6') || - modelValue.includes('v1-5') - ) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modelValue.includes('v1')) { - if (modeValue.includes('pro')) { - return durationValue.includes('10') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return durationValue.includes('10') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingImageGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const imageInputWidget = node.inputs?.find((i) => i.name === 'image') - // If link is not null => image is connected => modality is image to image - const modality = imageInputWidget?.link - ? 'image to image' - : 'text to image' - const modelWidget = node.widgets?.find( - (w) => w.name === 'model_name' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!modelWidget) - return formatCreditsRangeLabel(0.0035, 0.028, { - suffix: ' x n/Run', - note: '(varies with modality & model)' - }) - - const model = String(modelWidget.value) - const n = Number(nWidget?.value) || 1 - let basePrice = 0.014 // default - - if (modality.includes('text to image')) { - if (model.includes('kling-v1-5') || model.includes('kling-v2')) { - basePrice = 0.014 - } else if (model.includes('kling-v1')) { - basePrice = 0.0035 - } - } else if (modality.includes('image to image')) { - if (model.includes('kling-v1-5')) { - basePrice = 0.028 - } else if (model.includes('kling-v1')) { - basePrice = 0.0035 - } - } - - const totalCost = basePrice * n - return formatCreditsLabel(totalCost) - } - }, - KlingLipSyncAudioToVideoNode: { - displayPrice: formatCreditsLabel(0.1, { approximate: true }) - }, - KlingLipSyncTextToVideoNode: { - displayPrice: formatCreditsLabel(0.1, { approximate: true }) - }, - KlingSingleImageVideoEffectNode: { - displayPrice: (node: LGraphNode): string => { - const effectSceneWidget = node.widgets?.find( - (w) => w.name === 'effect_scene' - ) as IComboWidget - - if (!effectSceneWidget) - return formatCreditsRangeLabel(0.28, 0.49, { - note: '(varies with effect scene)' - }) - - const effectScene = String(effectSceneWidget.value) - if ( - effectScene.includes('fuzzyfuzzy') || - effectScene.includes('squish') - ) { - return formatCreditsLabel(0.28) - } else if (effectScene.includes('dizzydizzy')) { - return formatCreditsLabel(0.49) - } else if (effectScene.includes('bloombloom')) { - return formatCreditsLabel(0.49) - } else if (effectScene.includes('expansion')) { - return formatCreditsLabel(0.28) - } - - return formatCreditsLabel(0.28) - } - }, - KlingStartEndFrameNode: { - displayPrice: (node: LGraphNode): string => { - // Same pricing as KlingTextToVideoNode per CSV ("Same as text to video") - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - if (!modeWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - - // Same pricing matrix as KlingTextToVideoNode - if (modeValue.includes('v2-5-turbo')) { - if (modeValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if (modeValue.includes('v2-1')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(0.98) // pro, 10s - } - return formatCreditsLabel(0.49) // pro, 5s default - } else if (modeValue.includes('v2-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if (modeValue.includes('v1-6')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modeValue.includes('v1')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingTextToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modeWidget = node.widgets?.find( - (w) => w.name === 'mode' - ) as IComboWidget - if (!modeWidget) - return formatCreditsRangeLabel(0.14, 2.8, { - note: '(varies with model, mode & duration)' - }) - - const modeValue = String(modeWidget.value) - - // Pricing matrix from CSV data based on mode string content - if (modeValue.includes('v2-5-turbo')) { - if (modeValue.includes('10')) { - return formatCreditsLabel(0.7) - } - return formatCreditsLabel(0.35) // 5s default - } else if (modeValue.includes('v2-1-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) // price is the same as for v2-master model - } - return formatCreditsLabel(1.4) // price is the same as for v2-master model - } else if (modeValue.includes('v2-master')) { - if (modeValue.includes('10s')) { - return formatCreditsLabel(2.8) - } - return formatCreditsLabel(1.4) // 5s default - } else if (modeValue.includes('v1-6')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.56) - : formatCreditsLabel(0.28) - } - } else if (modeValue.includes('v1')) { - if (modeValue.includes('pro')) { - return modeValue.includes('10s') - ? formatCreditsLabel(0.98) - : formatCreditsLabel(0.49) - } else { - return modeValue.includes('10s') - ? formatCreditsLabel(0.28) - : formatCreditsLabel(0.14) - } - } - - return formatCreditsLabel(0.14) - } - }, - KlingVideoExtendNode: { - displayPrice: formatCreditsLabel(0.28) - }, - KlingVirtualTryOnNode: { - displayPrice: formatCreditsLabel(0.07) - }, - KlingOmniProTextToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProFirstLastFrameNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProImageToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.112) - }, - KlingOmniProVideoToVideoNode: { - displayPrice: makeOmniProDurationCalculator(0.168) - }, - KlingMotionControl: { - displayPrice: klingMotionControlPricingCalculator - }, - KlingOmniProEditVideoNode: { - displayPrice: formatCreditsLabel(0.168, { suffix: '/second' }) - }, - KlingOmniProImageNode: { - displayPrice: formatCreditsLabel(0.028) - }, - KlingTextToVideoWithAudio: { - displayPrice: klingVideoWithAudioPricingCalculator - }, - KlingImageToVideoWithAudio: { - displayPrice: klingVideoWithAudioPricingCalculator - }, - LumaImageToVideoNode: { - displayPrice: (node: LGraphNode): string => { - // Same pricing as LumaVideoNode per CSV - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.2, 16.4, { - note: '(varies with model, resolution & duration)' - }) - } - - const model = String(modelWidget.value) - const resolution = String(resolutionWidget.value).toLowerCase() - const duration = String(durationWidget.value) - - if (model.includes('ray-flash-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(3.13) - if (resolution.includes('1080p')) return formatCreditsLabel(0.79) - if (resolution.includes('720p')) return formatCreditsLabel(0.34) - if (resolution.includes('540p')) return formatCreditsLabel(0.2) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(5.65) - if (resolution.includes('1080p')) return formatCreditsLabel(1.42) - if (resolution.includes('720p')) return formatCreditsLabel(0.61) - if (resolution.includes('540p')) return formatCreditsLabel(0.36) - } - } else if (model.includes('ray-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(9.11) - if (resolution.includes('1080p')) return formatCreditsLabel(2.27) - if (resolution.includes('720p')) return formatCreditsLabel(1.02) - if (resolution.includes('540p')) return formatCreditsLabel(0.57) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(16.4) - if (resolution.includes('1080p')) return formatCreditsLabel(4.1) - if (resolution.includes('720p')) return formatCreditsLabel(1.83) - if (resolution.includes('540p')) return formatCreditsLabel(1.03) - } - } else if (model.includes('ray-1-6')) { - return formatCreditsLabel(0.5) - } - - return formatCreditsLabel(0.79) - } - }, - LumaVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.2, 16.4, { - note: '(varies with model, resolution & duration)' - }) - } - - const model = String(modelWidget.value) - const resolution = String(resolutionWidget.value).toLowerCase() - const duration = String(durationWidget.value) - - if (model.includes('ray-flash-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(3.13) - if (resolution.includes('1080p')) return formatCreditsLabel(0.79) - if (resolution.includes('720p')) return formatCreditsLabel(0.34) - if (resolution.includes('540p')) return formatCreditsLabel(0.2) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(5.65) - if (resolution.includes('1080p')) return formatCreditsLabel(1.42) - if (resolution.includes('720p')) return formatCreditsLabel(0.61) - if (resolution.includes('540p')) return formatCreditsLabel(0.36) - } - } else if (model.includes('ray-2')) { - if (duration.includes('5s')) { - if (resolution.includes('4k')) return formatCreditsLabel(9.11) - if (resolution.includes('1080p')) return formatCreditsLabel(2.27) - if (resolution.includes('720p')) return formatCreditsLabel(1.02) - if (resolution.includes('540p')) return formatCreditsLabel(0.57) - } else if (duration.includes('9s')) { - if (resolution.includes('4k')) return formatCreditsLabel(16.4) - if (resolution.includes('1080p')) return formatCreditsLabel(4.1) - if (resolution.includes('720p')) return formatCreditsLabel(1.83) - if (resolution.includes('540p')) return formatCreditsLabel(1.03) - } - } else if (model.includes('ray-1-6')) { - return formatCreditsLabel(0.5) - } - - return formatCreditsLabel(0.79) - } - }, - MinimaxImageToVideoNode: { - displayPrice: formatCreditsLabel(0.43) - }, - MinimaxTextToVideoNode: { - displayPrice: formatCreditsLabel(0.43) - }, - MinimaxHailuoVideoNode: { - displayPrice: (node: LGraphNode): string => { - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!resolutionWidget || !durationWidget) { - return formatCreditsRangeLabel(0.28, 0.56, { - note: '(varies with resolution & duration)' - }) - } - - const resolution = String(resolutionWidget.value) - const duration = String(durationWidget.value) - - if (resolution.includes('768P')) { - if (duration.includes('6')) return formatCreditsLabel(0.28) - if (duration.includes('10')) return formatCreditsLabel(0.56) - } else if (resolution.includes('1080P')) { - if (duration.includes('6')) return formatCreditsLabel(0.49) - } - - return formatCreditsLabel(0.43) // default median - } - }, - OpenAIDalle2: { - displayPrice: (node: LGraphNode): string => { - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!sizeWidget) - return formatCreditsRangeLabel(0.016, 0.02, { - suffix: ' x n/Run', - note: '(varies with size & n)' - }) - - const size = String(sizeWidget.value) - const n = Number(nWidget?.value) || 1 - let basePrice = 0.02 // default - - if (size.includes('1024x1024')) { - basePrice = 0.02 - } else if (size.includes('512x512')) { - basePrice = 0.018 - } else if (size.includes('256x256')) { - basePrice = 0.016 - } - - const totalCost = Number((basePrice * n).toFixed(3)) - return formatCreditsLabel(totalCost) - } - }, - OpenAIDalle3: { - displayPrice: (node: LGraphNode): string => { - // Get size and quality widgets - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - - if (!sizeWidget || !qualityWidget) - return formatCreditsRangeLabel(0.04, 0.12, { - note: '(varies with size & quality)' - }) - - const size = String(sizeWidget.value) - const quality = String(qualityWidget.value) - - // Pricing matrix based on CSV data - if (size.includes('1024x1024')) { - return quality.includes('hd') - ? formatCreditsLabel(0.08) - : formatCreditsLabel(0.04) - } else if (size.includes('1792x1024') || size.includes('1024x1792')) { - return quality.includes('hd') - ? formatCreditsLabel(0.12) - : formatCreditsLabel(0.08) - } - - // Default value - return formatCreditsLabel(0.04) - } - }, - OpenAIGPTImage1: { - displayPrice: (node: LGraphNode): string => { - const qualityWidget = node.widgets?.find( - (w) => w.name === 'quality' - ) as IComboWidget - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - - if (!qualityWidget) - return formatCreditsRangeLabel(0.011, 0.3, { - suffix: ' x n/Run', - note: '(varies with quality & n)' - }) - - const quality = String(qualityWidget.value) - const n = Number(nWidget?.value) || 1 - let range: [number, number] = [0.046, 0.07] // default medium - - if (quality.includes('high')) { - range = [0.167, 0.3] - } else if (quality.includes('medium')) { - range = [0.046, 0.07] - } else if (quality.includes('low')) { - range = [0.011, 0.02] - } - - if (n === 1) { - return formatCreditsRangeLabel(range[0], range[1]) - } - return formatCreditsRangeLabel(range[0], range[1], { - suffix: ` x ${n}/Run` - }) - } - }, - PixverseImageToVideoNode: { - displayPrice: pixversePricingCalculator - }, - PixverseTextToVideoNode: { - displayPrice: pixversePricingCalculator - }, - PixverseTransitionVideoNode: { - displayPrice: pixversePricingCalculator - }, - RecraftCreativeUpscaleNode: { - displayPrice: formatCreditsLabel(0.25) - }, - RecraftCrispUpscaleNode: { - displayPrice: formatCreditsLabel(0.004) - }, - RecraftGenerateColorFromImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftGenerateImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftGenerateVectorImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.08 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftImageInpaintingNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftImageToImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftRemoveBackgroundNode: { - displayPrice: formatCreditsLabel(0.01) - }, - RecraftReplaceBackgroundNode: { - displayPrice: formatCreditsLabel(0.04) - }, - RecraftTextToImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.04, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.04 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftTextToVectorNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.08, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.08 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - RecraftVectorizeImageNode: { - displayPrice: (node: LGraphNode): string => { - const nWidget = node.widgets?.find( - (w) => w.name === 'n' - ) as IComboWidget - if (!nWidget) return formatCreditsLabel(0.01, { suffix: ' x n/Run' }) - - const n = Number(nWidget.value) || 1 - const cost = Number((0.01 * n).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - StabilityStableImageSD_3_5Node: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) - return formatCreditsRangeLabel(0.035, 0.065, { - note: '(varies with model)' - }) - - const model = String(modelWidget.value).toLowerCase() - if (model.includes('large')) { - return formatCreditsLabel(0.065) - } else if (model.includes('medium')) { - return formatCreditsLabel(0.035) - } - - return formatCreditsLabel(0.035) - } - }, - StabilityStableImageUltraNode: { - displayPrice: formatCreditsLabel(0.08) - }, - StabilityUpscaleConservativeNode: { - displayPrice: formatCreditsLabel(0.25) - }, - StabilityUpscaleCreativeNode: { - displayPrice: formatCreditsLabel(0.25) - }, - StabilityUpscaleFastNode: { - displayPrice: formatCreditsLabel(0.01) - }, - StabilityTextToAudio: { - displayPrice: formatCreditsLabel(0.2) - }, - StabilityAudioToAudio: { - displayPrice: formatCreditsLabel(0.2) - }, - StabilityAudioInpaint: { - displayPrice: formatCreditsLabel(0.2) - }, - VeoVideoGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration_seconds' - ) as IComboWidget - - if (!durationWidget) - return formatCreditsRangeLabel(2.5, 5.0, { - note: '(varies with duration)' - }) - - const price = 0.5 * Number(durationWidget.value) - return formatCreditsLabel(price) - } - }, - Veo3VideoGenerationNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - - if (!modelWidget || !generateAudioWidget) { - return formatCreditsRangeLabel(0.8, 3.2, { - note: '(varies with model & audio generation)' - }) - } - - const model = String(modelWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - - if ( - model.includes('veo-3.0-fast-generate-001') || - model.includes('veo-3.1-fast-generate') - ) { - return generateAudio - ? formatCreditsLabel(1.2) - : formatCreditsLabel(0.8) - } else if ( - model.includes('veo-3.0-generate-001') || - model.includes('veo-3.1-generate') - ) { - return generateAudio - ? formatCreditsLabel(3.2) - : formatCreditsLabel(1.6) - } - - // Default fallback - return formatCreditsRangeLabel(0.8, 3.2) - } - }, - Veo3FirstLastFrameNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const generateAudioWidget = node.widgets?.find( - (w) => w.name === 'generate_audio' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - - if (!modelWidget || !generateAudioWidget || !durationWidget) { - return formatCreditsRangeLabel(0.4, 3.2, { - note: '(varies with model & audio generation)' - }) - } - - const model = String(modelWidget.value) - const generateAudio = - String(generateAudioWidget.value).toLowerCase() === 'true' - const seconds = parseFloat(String(durationWidget.value)) - - let pricePerSecond: number | null = null - if (model.includes('veo-3.1-fast-generate')) { - pricePerSecond = generateAudio ? 0.15 : 0.1 - } else if (model.includes('veo-3.1-generate')) { - pricePerSecond = generateAudio ? 0.4 : 0.2 - } - if (pricePerSecond === null) { - return formatCreditsRangeLabel(0.4, 3.2) - } - const cost = pricePerSecond * seconds - return formatCreditsLabel(cost) - } - }, - LumaImageNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const aspectRatioWidget = node.widgets?.find( - (w) => w.name === 'aspect_ratio' - ) as IComboWidget - - if (!modelWidget || !aspectRatioWidget) { - return formatCreditsRangeLabel(0.0064, 0.026, { - note: '(varies with model & aspect ratio)' - }) - } - - const model = String(modelWidget.value) - - if (model.includes('photon-flash-1')) { - return formatCreditsLabel(0.0027) - } else if (model.includes('photon-1')) { - return formatCreditsLabel(0.0104) - } - - return formatCreditsLabel(0.0246) - } - }, - LumaImageModifyNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - if (!modelWidget) { - return formatCreditsRangeLabel(0.0027, 0.0104, { - note: '(varies with model)' - }) - } - - const model = String(modelWidget.value) - - if (model.includes('photon-flash-1')) { - return formatCreditsLabel(0.0027) - } else if (model.includes('photon-1')) { - return formatCreditsLabel(0.0104) - } - - return formatCreditsLabel(0.0246) - } - }, - MoonvalleyTxt2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(1.5) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(1.5) - } else if (length === '10s') { - return formatCreditsLabel(3.0) - } - - return formatCreditsLabel(1.5) - } - }, - MoonvalleyImg2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(1.5) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(1.5) - } else if (length === '10s') { - return formatCreditsLabel(3.0) - } - - return formatCreditsLabel(1.5) - } - }, - MoonvalleyVideo2VideoNode: { - displayPrice: (node: LGraphNode): string => { - const lengthWidget = node.widgets?.find( - (w) => w.name === 'length' - ) as IComboWidget - - // If no length widget exists, default to 5s pricing - if (!lengthWidget) return formatCreditsLabel(2.25) - - const length = String(lengthWidget.value) - if (length === '5s') { - return formatCreditsLabel(2.25) - } else if (length === '10s') { - return formatCreditsLabel(4.0) - } - - return formatCreditsLabel(2.25) - } - }, - // Runway nodes - using actual node names from ComfyUI - RunwayTextToImageNode: { - displayPrice: formatCreditsLabel(0.11) - }, - RunwayImageToVideoNodeGen3a: { - displayPrice: calculateRunwayDurationPrice - }, - RunwayImageToVideoNodeGen4: { - displayPrice: calculateRunwayDurationPrice - }, - RunwayFirstLastFrameNode: { - displayPrice: calculateRunwayDurationPrice - }, - // Rodin nodes - all have the same pricing structure - Rodin3D_Regular: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Detail: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Smooth: { - displayPrice: formatCreditsLabel(0.4) - }, - Rodin3D_Sketch: { - displayPrice: formatCreditsLabel(0.4) - }, - // Tripo nodes - using actual node names from ComfyUI - TripoTextToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'text') - }, - TripoImageToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'image') - }, - TripoMultiviewToModelNode: { - displayPrice: (node: LGraphNode): string => - calculateTripo3DGenerationPrice(node, 'multiview') - }, - TripoTextureNode: { - displayPrice: (node: LGraphNode): string => { - const textureQualityWidget = node.widgets?.find( - (w) => w.name === 'texture_quality' - ) as IComboWidget - - if (!textureQualityWidget) - return formatCreditsRangeLabel(0.1, 0.2, { - note: '(varies with quality)' - }) - - const textureQuality = String(textureQualityWidget.value) - return textureQuality.includes('detailed') - ? formatCreditsLabel(0.2) - : formatCreditsLabel(0.1) - } - }, - TripoRigNode: { - displayPrice: '$0.25/Run' - }, - TripoConversionNode: { - displayPrice: (node: LGraphNode): string => { - const getWidgetValue = (name: string) => - node.widgets?.find((w) => w.name === name)?.value - - const getNumber = (name: string, defaultValue: number): number => { - const raw = getWidgetValue(name) - if (raw === undefined || raw === null || raw === '') - return defaultValue - if (typeof raw === 'number') - return Number.isFinite(raw) ? raw : defaultValue - const n = Number(raw) - return Number.isFinite(n) ? n : defaultValue - } - - const getBool = (name: string, defaultValue: boolean): boolean => { - const v = getWidgetValue(name) - if (v === undefined || v === null) return defaultValue - - if (typeof v === 'number') return v !== 0 - const lower = String(v).toLowerCase() - if (lower === 'true') return true - if (lower === 'false') return false - return defaultValue - } - - let hasAdvancedParam = false - - // ---- booleans that trigger advanced when true ---- - if (getBool('quad', false)) hasAdvancedParam = true - if (getBool('force_symmetry', false)) hasAdvancedParam = true - if (getBool('flatten_bottom', false)) hasAdvancedParam = true - if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true - if (getBool('with_animation', false)) hasAdvancedParam = true - if (getBool('pack_uv', false)) hasAdvancedParam = true - if (getBool('bake', false)) hasAdvancedParam = true - if (getBool('export_vertex_colors', false)) hasAdvancedParam = true - if (getBool('animate_in_place', false)) hasAdvancedParam = true - - // ---- numeric params with special default sentinels ---- - const faceLimit = getNumber('face_limit', -1) - if (faceLimit !== -1) hasAdvancedParam = true - - const textureSize = getNumber('texture_size', 4096) - if (textureSize !== 4096) hasAdvancedParam = true - - const flattenBottomThreshold = getNumber( - 'flatten_bottom_threshold', - 0.0 - ) - if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true - - const scaleFactor = getNumber('scale_factor', 1.0) - if (scaleFactor !== 1.0) hasAdvancedParam = true - - // ---- string / combo params with non-default values ---- - const textureFormatRaw = String( - getWidgetValue('texture_format') ?? 'JPEG' - ).toUpperCase() - if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true - - const partNamesRaw = String(getWidgetValue('part_names') ?? '') - if (partNamesRaw.trim().length > 0) hasAdvancedParam = true - - const fbxPresetRaw = String( - getWidgetValue('fbx_preset') ?? 'blender' - ).toLowerCase() - if (fbxPresetRaw !== 'blender') hasAdvancedParam = true - - const exportOrientationRaw = String( - getWidgetValue('export_orientation') ?? 'default' - ).toLowerCase() - if (exportOrientationRaw !== 'default') hasAdvancedParam = true - - const credits = hasAdvancedParam ? 10 : 5 - return formatCreditsLabel(credits * 0.01) - } - }, - TripoRetargetNode: { - displayPrice: formatCreditsLabel(0.1) - }, - TripoRefineNode: { - displayPrice: formatCreditsLabel(0.3) - }, - MeshyTextToModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(20)) - }, - MeshyRefineNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) - }, - MeshyImageToModelNode: { - displayPrice: calculateMeshyImageToModelPrice - }, - MeshyMultiImageToModelNode: { - displayPrice: calculateMeshyMultiImageToModelPrice - }, - MeshyRigModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(5)) - }, - MeshyAnimateModelNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(3)) - }, - MeshyTextureNode: { - displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) - }, - // 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) - - if (model.includes('gemini-2.5-flash-preview-04-17')) { - return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-flash')) { - return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-pro-preview-05-06')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-2.5-pro')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gemini-3-pro-preview')) { - return formatCreditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - // For other Gemini models, show token-based pricing info - return 'Token-based' - } - }, - GeminiImageNode: { - displayPrice: formatCreditsLabel(0.039, { - suffix: '/Image (1K)', - approximate: true - }) - }, - GeminiImage2Node: { - displayPrice: (node: LGraphNode): string => { - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!resolutionWidget) return 'Token-based' - - const resolution = String(resolutionWidget.value) - if (resolution.includes('1K')) { - return formatCreditsLabel(0.134, { - suffix: '/Image', - approximate: true - }) - } else if (resolution.includes('2K')) { - return formatCreditsLabel(0.134, { - suffix: '/Image', - approximate: true - }) - } else if (resolution.includes('4K')) { - return formatCreditsLabel(0.24, { - suffix: '/Image', - approximate: true - }) - } - 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 formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o1-pro')) { - return formatCreditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o1')) { - return formatCreditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o3-mini')) { - return formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('o3')) { - return formatCreditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4o')) { - return formatCreditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1-nano')) { - return formatCreditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1-mini')) { - return formatCreditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-4.1')) { - return formatCreditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5-nano')) { - return formatCreditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5-mini')) { - return formatCreditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } else if (model.includes('gpt-5')) { - return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens', - approximate: true, - separator: '-' - }) - } - return 'Token-based' - } - }, - ViduTextToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduImageToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduReferenceVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ViduStartEndToVideoNode: { - displayPrice: formatCreditsLabel(0.4) - }, - ByteDanceImageNode: { - 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) - - if (model.includes('seedream-3-0-t2i')) { - return formatCreditsLabel(0.03) - } - return 'Token-based' - } - }, - ByteDanceImageEditNode: { - 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) - - if (model.includes('seededit-3-0-i2i')) { - return formatCreditsLabel(0.03) - } - return 'Token-based' - } - }, - ByteDanceSeedreamNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - - const model = String(modelWidget?.value ?? '').toLowerCase() - let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback - if (model.includes('seedream-4-5-251128')) { - pricePerImage = 0.04 - } else if (model.includes('seedream-4-0-250828')) { - pricePerImage = 0.03 - } - return formatCreditsLabel(pricePerImage, { - suffix: ' x images/Run', - approximate: true - }) - } - }, - ByteDanceTextToVideoNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceImageToVideoNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceFirstLastFrameNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - ByteDanceImageReferenceNode: { - displayPrice: byteDanceVideoPricingCalculator - }, - WanTextToVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - const resolutionStr = String(resolutionWidget.value).toLowerCase() - - const resKey = resolutionStr.includes('1080') - ? '1080p' - : resolutionStr.includes('720') - ? '720p' - : resolutionStr.includes('480') - ? '480p' - : (resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? '') - - const pricePerSecond: Record = { - '480p': 0.05, - '720p': 0.1, - '1080p': 0.15 - } - - const pps = pricePerSecond[resKey] - if (isNaN(seconds) || !pps) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const cost = Number((pps * seconds).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - WanImageToVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const seconds = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).trim().toLowerCase() - - const pricePerSecond: Record = { - '480p': 0.05, - '720p': 0.1, - '1080p': 0.15 - } - - const pps = pricePerSecond[resolution] - if (isNaN(seconds) || !pps) - return formatCreditsRangeLabel(0.05, 0.15, { suffix: '/second' }) - - const cost = Number((pps * seconds).toFixed(2)) - return formatCreditsLabel(cost) - } - }, - WanTextToImageApi: { - displayPrice: formatCreditsLabel(0.03) - }, - WanImageToImageApi: { - displayPrice: formatCreditsLabel(0.03) - }, - LtxvApiTextToVideo: { - displayPrice: ltxvPricingCalculator - }, - LtxvApiImageToVideo: { - displayPrice: ltxvPricingCalculator - }, - WanReferenceVideoApi: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const sizeWidget = node.widgets?.find( - (w) => w.name === 'size' - ) as IComboWidget - - if (!durationWidget || !sizeWidget) { - return formatCreditsRangeLabel(0.7, 1.5, { - note: '(varies with size & duration)' - }) - } - - const seconds = parseFloat(String(durationWidget.value)) - const sizeStr = String(sizeWidget.value).toLowerCase() - - const rate = sizeStr.includes('1080p') ? 0.15 : 0.1 - const inputMin = 2 * rate - const inputMax = 5 * rate - const outputPrice = seconds * rate - - const minTotal = inputMin + outputPrice - const maxTotal = inputMax + outputPrice - - return formatCreditsRangeLabel(minTotal, maxTotal) - } - }, - Vidu2TextToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.075, 0.6, { - note: '(varies with duration & resolution)' - }) - } - - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - - // Text-to-Video uses Q2 model only - // 720P: Starts at $0.075, +$0.025/sec - // 1080P: Starts at $0.10, +$0.05/sec - let basePrice: number - let pricePerSecond: number - - if (resolution.includes('1080')) { - basePrice = 0.1 - pricePerSecond = 0.05 - } else { - // 720P default - basePrice = 0.075 - pricePerSecond = 0.025 - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsRangeLabel(0.075, 0.6, { - note: '(varies with duration & resolution)' - }) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - }, - Vidu2ImageToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!modelWidget || !durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const model = String(modelWidget.value).toLowerCase() - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - const is1080p = resolution.includes('1080') - - let basePrice: number - let pricePerSecond: number - - if (model.includes('q2-pro-fast')) { - // Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec - basePrice = is1080p ? 0.08 : 0.04 - pricePerSecond = is1080p ? 0.02 : 0.01 - } else if (model.includes('q2-pro')) { - // Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec - basePrice = is1080p ? 0.275 : 0.075 - pricePerSecond = is1080p ? 0.075 : 0.05 - } else if (model.includes('q2-turbo')) { - // Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec - if (is1080p) { - basePrice = 0.175 - pricePerSecond = 0.05 - } else { - // 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s - if (duration <= 1) { - return formatCreditsLabel(0.04) - } - if (duration <= 2) { - return formatCreditsLabel(0.05) - } - const cost = 0.05 + 0.05 * (duration - 2) - return formatCreditsLabel(cost) - } - } else { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - }, - Vidu2ReferenceVideoNode: { - displayPrice: (node: LGraphNode): string => { - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - const audioWidget = node.widgets?.find( - (w) => w.name === 'audio' - ) as IComboWidget - - if (!durationWidget) { - return formatCreditsRangeLabel(0.125, 1.5, { - note: '(varies with duration, resolution & audio)' - }) - } - - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget?.value ?? '').toLowerCase() - const is1080p = resolution.includes('1080') - - // Check if audio is enabled (adds $0.75) - const audioValue = audioWidget?.value - const hasAudio = - audioValue !== undefined && - audioValue !== null && - String(audioValue).toLowerCase() !== 'false' && - String(audioValue).toLowerCase() !== 'none' && - audioValue !== '' - - // Reference-to-Video uses Q2 model - // 720P: Starts at $0.125, +$0.025/sec - // 1080P: Starts at $0.375, +$0.05/sec - let basePrice: number - let pricePerSecond: number - - if (is1080p) { - basePrice = 0.375 - pricePerSecond = 0.05 - } else { - // 720P default - basePrice = 0.125 - pricePerSecond = 0.025 - } - - let cost = basePrice - if (Number.isFinite(duration) && duration > 0) { - cost = basePrice + pricePerSecond * (duration - 1) - } - - // Audio adds $0.75 on top - if (hasAudio) { - cost += 0.075 - } - - return formatCreditsLabel(cost) - } - }, - Vidu2StartEndToVideoNode: { - displayPrice: (node: LGraphNode): string => { - const modelWidget = node.widgets?.find( - (w) => w.name === 'model' - ) as IComboWidget - const durationWidget = node.widgets?.find( - (w) => w.name === 'duration' - ) as IComboWidget - const resolutionWidget = node.widgets?.find( - (w) => w.name === 'resolution' - ) as IComboWidget - - if (!modelWidget || !durationWidget || !resolutionWidget) { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - const model = String(modelWidget.value).toLowerCase() - const duration = parseFloat(String(durationWidget.value)) - const resolution = String(resolutionWidget.value).toLowerCase() - const is1080p = resolution.includes('1080') - - let basePrice: number - let pricePerSecond: number - - if (model.includes('q2-pro-fast')) { - // Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec - basePrice = is1080p ? 0.08 : 0.04 - pricePerSecond = is1080p ? 0.02 : 0.01 - } else if (model.includes('q2-pro')) { - // Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec - basePrice = is1080p ? 0.275 : 0.075 - pricePerSecond = is1080p ? 0.075 : 0.05 - } else if (model.includes('q2-turbo')) { - // Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec - if (is1080p) { - basePrice = 0.175 - pricePerSecond = 0.05 - } else { - // 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s - if (!Number.isFinite(duration) || duration <= 1) { - return formatCreditsLabel(0.04) - } - if (duration <= 2) { - return formatCreditsLabel(0.05) - } - const cost = 0.05 + 0.05 * (duration - 2) - return formatCreditsLabel(cost) - } - } else { - return formatCreditsRangeLabel(0.04, 1.0, { - note: '(varies with model, duration & resolution)' - }) - } - - if (!Number.isFinite(duration) || duration <= 0) { - return formatCreditsLabel(basePrice) - } - - const cost = basePrice + pricePerSecond * (duration - 1) - return formatCreditsLabel(cost) - } - } - } - -/** - * Composable to get node pricing information for API nodes - */ +// ----------------------------- +// Public composable API +// ----------------------------- export const useNodePricing = () => { /** - * Get the price display for a node + * Sync getter: + * - returns cached label for the current node signature when available + * - schedules async evaluation when needed + * - remains non-fatal on errors (returns safe fallback '') */ const getNodeDisplayPrice = (node: LGraphNode): string => { - if (!node.constructor?.nodeData?.api_node) return '' + // Make this function reactive: when async evaluation completes, we bump pricingTick, + // which causes this getter to recompute in Vue render/computed contexts. + void pricingTick.value - const nodeName = node.constructor.nodeData.name - const priceConfig = apiNodeCosts[nodeName] + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return '' - if (!priceConfig) return '' + const rule = getRuleForNode(node) + if (!rule) return '' + if (rule.engine !== 'jsonata') return '' + if (!rule._compiled) return '' - // If it's a function, call it with the node to get dynamic pricing - if (typeof priceConfig.displayPrice === 'function') { - return safePricingExecution(priceConfig.displayPrice, node, '') + const ctx = buildJsonataContext(node, rule) + const sig = buildSignature(ctx, rule) + + const cached = cache.get(node) + if (cached && cached.sig === sig) { + return cached.label } - // Otherwise return the static price - return priceConfig.displayPrice + // Cache miss: start async evaluation. + // Return last-known label (if any) to avoid flicker; otherwise return empty. + scheduleEvaluation(node, rule, ctx, sig) + return cached?.label ?? '' } - const getNodePricingConfig = (node: LGraphNode) => - apiNodeCosts[node.constructor.nodeData?.name ?? ''] + /** + * Expose raw pricing config for tooling/debug UI. + * (Strips compiled expression from returned object.) + */ + const getNodePricingConfig = (node: LGraphNode) => { + const rule = getRuleForNode(node) + if (!rule) return undefined + const { _compiled, ...config } = rule + return config + } + /** + * Caller compatibility helper: + * returns union of widget dependencies + input dependencies for a node type. + */ const getRelevantWidgetNames = (nodeType: string): string[] => { - const widgetMap: Record = { - KlingTextToVideoNode: ['mode', 'model_name', 'duration'], - KlingImage2VideoNode: ['mode', 'model_name', 'duration'], - KlingImageGenerationNode: ['modality', 'model_name', 'n'], - KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'], - KlingSingleImageVideoEffectNode: ['effect_scene'], - KlingStartEndFrameNode: ['mode', 'model_name', 'duration'], - KlingTextToVideoWithAudio: ['duration', 'generate_audio'], - KlingImageToVideoWithAudio: ['duration', 'generate_audio'], - KlingOmniProTextToVideoNode: ['duration'], - KlingOmniProFirstLastFrameNode: ['duration'], - KlingOmniProImageToVideoNode: ['duration'], - KlingOmniProVideoToVideoNode: ['duration'], - KlingMotionControl: ['mode'], - MinimaxHailuoVideoNode: ['resolution', 'duration'], - OpenAIDalle3: ['size', 'quality'], - OpenAIDalle2: ['size', 'n'], - OpenAIVideoSora2: ['model', 'size', 'duration'], - OpenAIGPTImage1: ['quality', 'n'], - IdeogramV1: ['num_images', 'turbo'], - IdeogramV2: ['num_images', 'turbo'], - IdeogramV3: ['rendering_speed', 'num_images', 'character_image'], - FluxProKontextProNode: [], - FluxProKontextMaxNode: [], - Flux2ProImageNode: ['width', 'height', 'images'], - Flux2MaxImageNode: ['width', 'height', 'images'], - VeoVideoGenerationNode: ['duration_seconds'], - Veo3VideoGenerationNode: ['model', 'generate_audio'], - Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'], - LumaVideoNode: ['model', 'resolution', 'duration'], - LumaImageToVideoNode: ['model', 'resolution', 'duration'], - LumaImageNode: ['model', 'aspect_ratio'], - LumaImageModifyNode: ['model', 'aspect_ratio'], - PixverseTextToVideoNode: ['duration_seconds', 'quality', 'motion_mode'], - PixverseTransitionVideoNode: [ - 'duration_seconds', - 'motion_mode', - 'quality' - ], - PixverseImageToVideoNode: ['duration_seconds', 'quality', 'motion_mode'], - StabilityStableImageSD_3_5Node: ['model'], - RecraftTextToImageNode: ['n'], - RecraftImageToImageNode: ['n'], - RecraftImageInpaintingNode: ['n'], - RecraftTextToVectorNode: ['n'], - RecraftVectorizeImageNode: ['n'], - RecraftGenerateColorFromImageNode: ['n'], - RecraftGenerateImageNode: ['n'], - RecraftGenerateVectorImageNode: ['n'], - MoonvalleyTxt2VideoNode: ['length'], - MoonvalleyImg2VideoNode: ['length'], - MoonvalleyVideo2VideoNode: ['length'], - // Runway nodes - RunwayImageToVideoNodeGen3a: ['duration'], - RunwayImageToVideoNodeGen4: ['duration'], - RunwayFirstLastFrameNode: ['duration'], - // Tripo nodes - TripoTextToModelNode: [ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoImageToModelNode: [ - 'model_version', - 'quad', - 'style', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoMultiviewToModelNode: [ - 'model_version', - 'quad', - 'texture', - 'pbr', - 'texture_quality', - 'geometry_quality' - ], - TripoConversionNode: [ - 'quad', - 'face_limit', - 'texture_size', - 'texture_format', - 'force_symmetry', - 'flatten_bottom', - 'flatten_bottom_threshold', - 'pivot_to_center_bottom', - 'scale_factor', - 'with_animation', - 'pack_uv', - 'bake', - 'part_names', - 'fbx_preset', - 'export_vertex_colors', - 'export_orientation', - 'animate_in_place' - ], - TripoTextureNode: ['texture_quality'], - // Meshy nodes - MeshyImageToModelNode: ['should_texture'], - MeshyMultiImageToModelNode: ['should_texture'], - // Google/Gemini nodes - GeminiNode: ['model'], - GeminiImage2Node: ['resolution'], - // OpenAI nodes - OpenAIChatNode: ['model'], - // ByteDance - ByteDanceImageNode: ['model'], - ByteDanceImageEditNode: ['model'], - ByteDanceSeedreamNode: [ - 'model', - 'sequential_image_generation', - 'max_images' - ], - ByteDanceTextToVideoNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceImageToVideoNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceFirstLastFrameNode: [ - 'model', - 'duration', - 'resolution', - 'generate_audio' - ], - ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], - WanTextToVideoApi: ['duration', 'size'], - WanImageToVideoApi: ['duration', 'resolution'], - WanReferenceVideoApi: ['duration', 'size'], - LtxvApiTextToVideo: ['model', 'duration', 'resolution'], - LtxvApiImageToVideo: ['model', 'duration', 'resolution'], - Vidu2TextToVideoNode: ['model', 'duration', 'resolution'], - Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'], - Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'], - Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution'] + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + const dependsOn = priceBadge.depends_on ?? { + widgets: [], + inputs: [], + input_groups: [] } - return widgetMap[nodeType] || [] + + // Extract widget names + const widgetNames = (dependsOn.widgets ?? []).map((w) => w.name) + + // Keep stable output (dedupe while preserving order) + const out: string[] = [] + for (const n of [ + ...widgetNames, + ...(dependsOn.inputs ?? []), + ...(dependsOn.input_groups ?? []) + ]) { + if (!out.includes(n)) out.push(n) + } + return out + } + + /** + * Check if a node type has dynamic pricing (depends on widgets, inputs, or input_groups). + */ + const hasDynamicPricing = (nodeType: string): boolean => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return false + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return false + + const dependsOn = priceBadge.depends_on + if (!dependsOn) return false + + return ( + (dependsOn.widgets?.length ?? 0) > 0 || + (dependsOn.inputs?.length ?? 0) > 0 || + (dependsOn.input_groups?.length ?? 0) > 0 + ) + } + + /** + * Get input_groups prefixes for a node type (for watching connection changes). + */ + const getInputGroupPrefixes = (nodeType: string): string[] => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + return priceBadge.depends_on?.input_groups ?? [] + } + + /** + * Get regular input names for a node type (for watching connection changes). + */ + const getInputNames = (nodeType: string): string[] => { + const nodeDefStore = useNodeDefStore() + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) return [] + + const priceBadge = nodeDef.price_badge + if (!priceBadge) return [] + + return priceBadge.depends_on?.inputs ?? [] + } + + /** + * Trigger price recalculation for a node (call when inputs change). + * Forces re-evaluation by calling getNodeDisplayPrice which will detect + * the signature change and schedule a new evaluation. + */ + const triggerPriceRecalculation = (node: LGraphNode): void => { + const nodeData = getNodeConstructorData(node) + if (!nodeData?.api_node) return + + // Call getNodeDisplayPrice to trigger evaluation if signature changed + getNodeDisplayPrice(node) } return { getNodeDisplayPrice, getNodePricingConfig, - getRelevantWidgetNames + getRelevantWidgetNames, + hasDynamicPricing, + getInputGroupPrefixes, + getInputNames, + getNodeRevisionRef, // Each node has its own independent ref, so updates to one won't trigger others + triggerPriceRecalculation, + pricingRevision: readonly(pricingTick) // reactive invalidation signal } } diff --git a/src/composables/node/useWatchWidget.test.ts b/src/composables/node/useWatchWidget.test.ts index 61363a3f1..346d838a3 100644 --- a/src/composables/node/useWatchWidget.test.ts +++ b/src/composables/node/useWatchWidget.test.ts @@ -3,11 +3,12 @@ import { nextTick } from 'vue' import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' // Mock useChainCallback vi.mock('@/composables/functional/useChainCallback', () => ({ useChainCallback: vi.fn((original, newCallback) => { - return function (this: any, ...args: any[]) { + return function (this: unknown, ...args: unknown[]) { original?.call(this, ...args) newCallback.call(this, ...args) } @@ -18,11 +19,12 @@ describe('useComputedWithWidgetWatch', () => { const createMockNode = ( widgets: Array<{ name: string - value: any - callback?: (...args: any[]) => void + value: unknown + callback?: (...args: unknown[]) => void }> = [] - ) => { - const mockNode = { + ): LGraphNode => { + const baseNode = createMockLGraphNode() + return Object.assign(baseNode, { widgets: widgets.map((widget) => ({ name: widget.name, value: widget.value, @@ -31,9 +33,7 @@ describe('useComputedWithWidgetWatch', () => { graph: { setDirtyCanvas: vi.fn() } - } as unknown as LGraphNode - - return mockNode + }) } it('should create a reactive computed that responds to widget changes', async () => { @@ -59,9 +59,9 @@ describe('useComputedWithWidgetWatch', () => { // Change widget value and trigger callback const widthWidget = mockNode.widgets?.find((w) => w.name === 'width') - if (widthWidget) { + if (widthWidget && widthWidget.callback) { widthWidget.value = 150 - ;(widthWidget.callback as any)?.() + widthWidget.callback(widthWidget.value) } await nextTick() @@ -89,9 +89,9 @@ describe('useComputedWithWidgetWatch', () => { // Change observed widget const widthWidget = mockNode.widgets?.find((w) => w.name === 'width') - if (widthWidget) { + if (widthWidget && widthWidget.callback) { widthWidget.value = 150 - ;(widthWidget.callback as any)?.() + widthWidget.callback(widthWidget.value) } await nextTick() @@ -117,9 +117,9 @@ describe('useComputedWithWidgetWatch', () => { // Change widget value const widget = mockNode.widgets?.[0] - if (widget) { + if (widget && widget.callback) { widget.value = 20 - ;(widget.callback as any)?.() + widget.callback(widget.value) } await nextTick() @@ -139,9 +139,9 @@ describe('useComputedWithWidgetWatch', () => { // Change widget value const widget = mockNode.widgets?.[0] - if (widget) { + if (widget && widget.callback) { widget.value = 20 - ;(widget.callback as any)?.() + widget.callback(widget.value) } await nextTick() @@ -171,8 +171,8 @@ describe('useComputedWithWidgetWatch', () => { // Trigger widget callback const widget = mockNode.widgets?.[0] - if (widget) { - ;(widget.callback as any)?.() + if (widget && widget.callback) { + widget.callback(widget.value) } await nextTick() diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts index a2a061f78..0aaaa892c 100644 --- a/src/composables/queue/useJobList.test.ts +++ b/src/composables/queue/useJobList.test.ts @@ -305,24 +305,40 @@ describe('useJobList', () => { expect(vi.getTimerCount()).toBe(0) }) - it('sorts all tasks by priority descending', async () => { + it('sorts all tasks by create time', async () => { queueStoreMock.pendingTasks = [ - createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' }) + createTask({ + promptId: 'p', + queueIndex: 1, + mockState: 'pending', + createTime: 3000 + }) ] queueStoreMock.runningTasks = [ - createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' }) + createTask({ + promptId: 'r', + queueIndex: 5, + mockState: 'running', + createTime: 2000 + }) ] queueStoreMock.historyTasks = [ - createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' }) + createTask({ + promptId: 'h', + queueIndex: 3, + mockState: 'completed', + createTime: 1000, + executionEndTimestamp: 5000 + }) ] const { allTasksSorted } = initComposable() await flush() expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([ + 'p', 'r', - 'h', - 'p' + 'h' ]) }) diff --git a/src/composables/queue/useJobList.ts b/src/composables/queue/useJobList.ts index b745a7103..9d5a73e50 100644 --- a/src/composables/queue/useJobList.ts +++ b/src/composables/queue/useJobList.ts @@ -1,3 +1,4 @@ +import { orderBy } from 'es-toolkit/array' import { computed, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -197,13 +198,15 @@ export function useJobList() { const selectedWorkflowFilter = ref<'all' | 'current'>('all') const selectedSortMode = ref('mostRecent') + const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0 + const allTasksSorted = computed(() => { const all = [ ...queueStore.pendingTasks, ...queueStore.runningTasks, ...queueStore.historyTasks ] - return all.sort((a, b) => b.queueIndex - a.queueIndex) + return orderBy(all, [mostRecentTimestamp], ['desc']) }) const tasksWithJobState = computed(() => diff --git a/src/composables/queue/useJobMenu.test.ts b/src/composables/queue/useJobMenu.test.ts index 1b339d09c..eea3d9232 100644 --- a/src/composables/queue/useJobMenu.test.ts +++ b/src/composables/queue/useJobMenu.test.ts @@ -5,15 +5,24 @@ import type { Ref } from 'vue' import type { JobListItem } from '@/composables/queue/useJobList' import type { MenuEntry } from '@/composables/queue/useJobMenu' +vi.mock('@/platform/distribution/types', () => ({ + isCloud: false +})) + const downloadFileMock = vi.fn() vi.mock('@/base/common/downloadUtil', () => ({ - downloadFile: (...args: any[]) => downloadFileMock(...args) + downloadFile: (url: string, filename?: string) => { + if (filename === undefined) { + return downloadFileMock(url) + } + return downloadFileMock(url, filename) + } })) const copyToClipboardMock = vi.fn() vi.mock('@/composables/useCopyToClipboard', () => ({ useCopyToClipboard: () => ({ - copyToClipboard: (...args: any[]) => copyToClipboardMock(...args) + copyToClipboard: (text: string) => copyToClipboardMock(text) }) })) @@ -26,12 +35,12 @@ vi.mock('@/i18n', () => ({ const mapTaskOutputToAssetItemMock = vi.fn() vi.mock('@/platform/assets/composables/media/assetMappers', () => ({ - mapTaskOutputToAssetItem: (...args: any[]) => - mapTaskOutputToAssetItemMock(...args) + mapTaskOutputToAssetItem: (taskItem: TaskItemImpl, output: ResultItemImpl) => + mapTaskOutputToAssetItemMock(taskItem, output) })) const mediaAssetActionsMock = { - confirmDelete: vi.fn() + deleteAssets: vi.fn() } vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({ useMediaAssetActions: () => mediaAssetActionsMock @@ -55,21 +64,24 @@ const workflowStoreMock = { createTemporary: vi.fn() } vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ - useWorkflowStore: () => workflowStoreMock + useWorkflowStore: () => workflowStoreMock, + ComfyWorkflow: class {} })) const interruptMock = vi.fn() const deleteItemMock = vi.fn() vi.mock('@/scripts/api', () => ({ api: { - interrupt: (...args: any[]) => interruptMock(...args), - deleteItem: (...args: any[]) => deleteItemMock(...args) + interrupt: (runningPromptId: string | null) => + interruptMock(runningPromptId), + deleteItem: (type: string, id: string) => deleteItemMock(type, id) } })) const downloadBlobMock = vi.fn() vi.mock('@/scripts/utils', () => ({ - downloadBlob: (...args: any[]) => downloadBlobMock(...args) + downloadBlob: (filename: string, blob: Blob) => + downloadBlobMock(filename, blob) })) const dialogServiceMock = { @@ -89,11 +101,14 @@ vi.mock('@/services/litegraphService', () => ({ useLitegraphService: () => litegraphServiceMock })) -const nodeDefStoreMock = { - nodeDefsByName: {} as Record +const nodeDefStoreMock: { + nodeDefsByName: Record> +} = { + nodeDefsByName: {} } vi.mock('@/stores/nodeDefStore', () => ({ - useNodeDefStore: () => nodeDefStoreMock + useNodeDefStore: () => nodeDefStoreMock, + ComfyNodeDefImpl: class {} })) const queueStoreMock = { @@ -104,14 +119,22 @@ vi.mock('@/stores/queueStore', () => ({ useQueueStore: () => queueStoreMock })) +const executionStoreMock = { + clearInitializationByPromptId: vi.fn() +} +vi.mock('@/stores/executionStore', () => ({ + useExecutionStore: () => executionStoreMock +})) + const getJobWorkflowMock = vi.fn() vi.mock('@/services/jobOutputCache', () => ({ - getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args) + getJobWorkflow: (jobId: string) => getJobWorkflowMock(jobId) })) const createAnnotatedPathMock = vi.fn() vi.mock('@/utils/createAnnotatedPath', () => ({ - createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args) + createAnnotatedPath: (filename: string, subfolder: string, type: string) => + createAnnotatedPathMock(filename, subfolder, type) })) const appendJsonExtMock = vi.fn((value: string) => @@ -123,7 +146,8 @@ vi.mock('@/utils/formatUtil', () => ({ })) import { useJobMenu } from '@/composables/queue/useJobMenu' -import type { TaskItemImpl } from '@/stores/queueStore' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' type MockTaskRef = Record @@ -174,16 +198,16 @@ describe('useJobMenu', () => { })) queueStoreMock.update.mockResolvedValue(undefined) queueStoreMock.delete.mockResolvedValue(undefined) - mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(false) mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({ task, output })) createAnnotatedPathMock.mockReturnValue('annotated-path') nodeDefStoreMock.nodeDefsByName = { - LoadImage: { id: 'LoadImage' }, - LoadVideo: { id: 'LoadVideo' }, - LoadAudio: { id: 'LoadAudio' } + LoadImage: { name: 'LoadImage' }, + LoadVideo: { name: 'LoadVideo' }, + LoadAudio: { name: 'LoadAudio' } } // Default: no workflow available via lazy loading getJobWorkflowMock.mockResolvedValue(undefined) @@ -245,7 +269,7 @@ describe('useJobMenu', () => { ['initialization', interruptMock, deleteItemMock] ])('cancels %s job via interrupt', async (state) => { const { cancelJob } = mountJobMenu() - setCurrentItem(createJobItem({ state: state as any })) + setCurrentItem(createJobItem({ state: state as JobListItem['state'] })) await cancelJob() @@ -280,7 +304,9 @@ describe('useJobMenu', () => { setCurrentItem( createJobItem({ state: 'failed', - taskRef: { errorMessage: 'Something went wrong' } as any + taskRef: { + errorMessage: 'Something went wrong' + } as Partial }) ) @@ -312,7 +338,7 @@ describe('useJobMenu', () => { errorMessage: 'CUDA out of memory', executionError, createTime: 12345 - } as any + } as Partial }) ) @@ -332,7 +358,9 @@ describe('useJobMenu', () => { setCurrentItem( createJobItem({ state: 'failed', - taskRef: { errorMessage: 'Job failed with error' } as any + taskRef: { + errorMessage: 'Job failed with error' + } as Partial }) ) @@ -354,7 +382,7 @@ describe('useJobMenu', () => { setCurrentItem( createJobItem({ state: 'failed', - taskRef: { errorMessage: undefined } as any + taskRef: { errorMessage: undefined } as Partial }) ) @@ -502,7 +530,12 @@ describe('useJobMenu', () => { it('ignores add-to-current entry when preview missing entirely', async () => { const { jobMenuEntries } = mountJobMenu() - setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any })) + setCurrentItem( + createJobItem({ + state: 'completed', + taskRef: {} as Partial + }) + ) await nextTick() const entry = findActionEntry(jobMenuEntries.value, 'add-to-current') @@ -531,7 +564,12 @@ describe('useJobMenu', () => { it('ignores download request when preview missing', async () => { const { jobMenuEntries } = mountJobMenu() - setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any })) + setCurrentItem( + createJobItem({ + state: 'completed', + taskRef: {} as Partial + }) + ) await nextTick() const entry = findActionEntry(jobMenuEntries.value, 'download') @@ -628,7 +666,7 @@ describe('useJobMenu', () => { }) it('deletes preview asset when confirmed', async () => { - mediaAssetActionsMock.confirmDelete.mockResolvedValue(true) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(true) const { jobMenuEntries } = mountJobMenu() const preview = { filename: 'foo', subfolder: 'bar', type: 'output' } const taskRef = { previewOutput: preview } @@ -643,7 +681,7 @@ describe('useJobMenu', () => { }) it('does not refresh queue when delete cancelled', async () => { - mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) + mediaAssetActionsMock.deleteAssets.mockResolvedValue(false) const { jobMenuEntries } = mountJobMenu() setCurrentItem( createJobItem({ @@ -739,7 +777,7 @@ describe('useJobMenu', () => { setCurrentItem( createJobItem({ state: 'failed', - taskRef: { errorMessage: 'Some error' } as any + taskRef: { errorMessage: 'Some error' } as Partial }) ) diff --git a/src/composables/queue/useJobMenu.ts b/src/composables/queue/useJobMenu.ts index b4208304e..bdf7033f6 100644 --- a/src/composables/queue/useJobMenu.ts +++ b/src/composables/queue/useJobMenu.ts @@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { st, t } from '@/i18n' import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers' import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions' +import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' @@ -15,6 +16,7 @@ import { downloadBlob } from '@/scripts/utils' import { useDialogService } from '@/services/dialogService' import { getJobWorkflow } from '@/services/jobOutputCache' import { useLitegraphService } from '@/services/litegraphService' +import { useExecutionStore } from '@/stores/executionStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useQueueStore } from '@/stores/queueStore' import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' @@ -44,6 +46,7 @@ export function useJobMenu( const workflowStore = useWorkflowStore() const workflowService = useWorkflowService() const queueStore = useQueueStore() + const executionStore = useExecutionStore() const { copyToClipboard } = useCopyToClipboard() const litegraphService = useLitegraphService() const nodeDefStore = useNodeDefStore() @@ -72,10 +75,15 @@ export function useJobMenu( const target = resolveItem(item) if (!target) return if (target.state === 'running' || target.state === 'initialization') { - await api.interrupt(target.id) + if (isCloud) { + await api.deleteItem('queue', target.id) + } else { + await api.interrupt(target.id) + } } else if (target.state === 'pending') { await api.deleteItem('queue', target.id) } + executionStore.clearInitializationByPromptId(target.id) await queueStore.update() } @@ -202,8 +210,8 @@ export function useJobMenu( if (!task || !preview) return const asset = mapTaskOutputToAssetItem(task, preview) - const success = await mediaAssetActions.confirmDelete(asset) - if (success) { + const confirmed = await mediaAssetActions.deleteAssets(asset) + if (confirmed) { await queueStore.update() } } diff --git a/src/composables/sidebarTabs/useAssetsSidebarTab.ts b/src/composables/sidebarTabs/useAssetsSidebarTab.ts index ce3753173..cd8e8d0bb 100644 --- a/src/composables/sidebarTabs/useAssetsSidebarTab.ts +++ b/src/composables/sidebarTabs/useAssetsSidebarTab.ts @@ -1,6 +1,7 @@ import { markRaw } from 'vue' import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue' +import { useQueueStore } from '@/stores/queueStore' import type { SidebarTabExtension } from '@/types/extensionTypes' export const useAssetsSidebarTab = (): SidebarTabExtension => { @@ -11,6 +12,12 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => { tooltip: 'sideToolbar.assets', label: 'sideToolbar.labels.assets', component: markRaw(AssetsSidebarTab), - type: 'vue' + type: 'vue', + iconBadge: () => { + const queueStore = useQueueStore() + return queueStore.pendingTasks.length > 0 + ? queueStore.pendingTasks.length.toString() + : null + } } } diff --git a/src/composables/useBrowserTabTitle.test.ts b/src/composables/useBrowserTabTitle.test.ts index 3c0cc623f..d25ec4fd2 100644 --- a/src/composables/useBrowserTabTitle.test.ts +++ b/src/composables/useBrowserTabTitle.test.ts @@ -11,13 +11,28 @@ vi.mock('@/i18n', () => ({ })) // Mock the execution store -const executionStore = reactive({ +const executionStore = reactive<{ + isIdle: boolean + executionProgress: number + executingNode: unknown + executingNodeProgress: number + nodeProgressStates: Record + activePrompt: { + workflow: { + changeTracker: { + activeState: { + nodes: { id: number; type: string }[] + } + } + } + } | null +}>({ isIdle: true, executionProgress: 0, - executingNode: null as any, + executingNode: null, executingNodeProgress: 0, - nodeProgressStates: {} as any, - activePrompt: null as any + nodeProgressStates: {}, + activePrompt: null }) vi.mock('@/stores/executionStore', () => ({ useExecutionStore: () => executionStore @@ -25,15 +40,21 @@ vi.mock('@/stores/executionStore', () => ({ // Mock the setting store const settingStore = reactive({ - get: vi.fn(() => 'Enabled') + get: vi.fn((_key: string) => 'Enabled') }) vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: () => settingStore })) // Mock the workflow store -const workflowStore = reactive({ - activeWorkflow: null as any +const workflowStore = reactive<{ + activeWorkflow: { + filename: string + isModified: boolean + isPersisted: boolean + } | null +}>({ + activeWorkflow: null }) vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ useWorkflowStore: () => workflowStore @@ -52,13 +73,13 @@ describe('useBrowserTabTitle', () => { // reset execution store executionStore.isIdle = true executionStore.executionProgress = 0 - executionStore.executingNode = null as any + executionStore.executingNode = null executionStore.executingNodeProgress = 0 executionStore.nodeProgressStates = {} executionStore.activePrompt = null // reset setting and workflow stores - ;(settingStore.get as any).mockReturnValue('Enabled') + vi.mocked(settingStore.get).mockReturnValue('Enabled') workflowStore.activeWorkflow = null workspaceStore.shiftDown = false @@ -74,7 +95,7 @@ describe('useBrowserTabTitle', () => { }) it('sets workflow name as title when workflow exists and menu enabled', async () => { - ;(settingStore.get as any).mockReturnValue('Enabled') + vi.mocked(settingStore.get).mockReturnValue('Enabled') workflowStore.activeWorkflow = { filename: 'myFlow', isModified: false, @@ -88,7 +109,7 @@ describe('useBrowserTabTitle', () => { }) it('adds asterisk for unsaved workflow', async () => { - ;(settingStore.get as any).mockReturnValue('Enabled') + vi.mocked(settingStore.get).mockReturnValue('Enabled') workflowStore.activeWorkflow = { filename: 'myFlow', isModified: true, @@ -102,7 +123,7 @@ describe('useBrowserTabTitle', () => { }) it('hides asterisk when autosave is enabled', async () => { - ;(settingStore.get as any).mockImplementation((key: string) => { + vi.mocked(settingStore.get).mockImplementation((key: string) => { if (key === 'Comfy.Workflow.AutoSave') return 'after delay' if (key === 'Comfy.UseNewMenu') return 'Enabled' return 'Enabled' @@ -118,7 +139,7 @@ describe('useBrowserTabTitle', () => { }) it('hides asterisk while Shift key is held', async () => { - ;(settingStore.get as any).mockImplementation((key: string) => { + vi.mocked(settingStore.get).mockImplementation((key: string) => { if (key === 'Comfy.Workflow.AutoSave') return 'off' if (key === 'Comfy.UseNewMenu') return 'Enabled' return 'Enabled' @@ -137,7 +158,7 @@ describe('useBrowserTabTitle', () => { // Fails when run together with other tests. Suspect to be caused by leaked // state from previous tests. it.skip('disables workflow title when menu disabled', async () => { - ;(settingStore.get as any).mockReturnValue('Disabled') + vi.mocked(settingStore.get).mockReturnValue('Disabled') workflowStore.activeWorkflow = { filename: 'myFlow', isModified: false, diff --git a/src/composables/useCachedRequest.test.ts b/src/composables/useCachedRequest.test.ts index 08faa3aaf..06d344ae1 100644 --- a/src/composables/useCachedRequest.test.ts +++ b/src/composables/useCachedRequest.test.ts @@ -4,7 +4,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest' describe('useCachedRequest', () => { let mockRequestFn: ( - params: any, + params: unknown, signal?: AbortSignal ) => Promise let abortSpy: () => void @@ -25,7 +25,7 @@ describe('useCachedRequest', () => { ) // Create a mock request function that returns different results based on params - mockRequestFn = vi.fn(async (params: any) => { + mockRequestFn = vi.fn(async (params: unknown) => { // Simulate a request that takes some time await new Promise((resolve) => setTimeout(resolve, 8)) @@ -138,12 +138,18 @@ describe('useCachedRequest', () => { it('should use custom cache key function if provided', async () => { // Create a cache key function that sorts object keys - const cacheKeyFn = (params: any) => { + const cacheKeyFn = (params: unknown) => { if (typeof params !== 'object' || params === null) return String(params) return JSON.stringify( - Object.keys(params) + Object.keys(params as Record) .sort() - .reduce((acc, key) => ({ ...acc, [key]: params[key] }), {}) + .reduce( + (acc, key) => ({ + ...acc, + [key]: (params as Record)[key] + }), + {} + ) ) } diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index 7b031d7ee..0c69343f5 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -123,8 +123,7 @@ export const useContextMenuTranslation = () => { } // for capture translation text of input and widget - const extraInfo = (options.extra || - options.parentMenu?.options?.extra) as + const extraInfo = (options.extra || options.parentMenu?.options?.extra) as | { inputs?: INodeInputSlot[]; widgets?: IWidget[] } | undefined // widgets and inputs diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index 82e3439bc..6f1f3cfb6 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { useCoreCommands } from '@/composables/useCoreCommands' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' // Mock vue-i18n for useExternalLink const mockLocale = ref('en') @@ -106,30 +108,89 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ })) describe('useCoreCommands', () => { - const mockSubgraph = { - nodes: [ - // Mock input node - { - constructor: { comfyClass: 'SubgraphInputNode' }, - id: 'input1' - }, - // Mock output node - { - constructor: { comfyClass: 'SubgraphOutputNode' }, - id: 'output1' - }, - // Mock user node - { - constructor: { comfyClass: 'SomeUserNode' }, - id: 'user1' - }, - // Another mock user node - { - constructor: { comfyClass: 'AnotherUserNode' }, - id: 'user2' + const createMockNode = (id: number, comfyClass: string): LGraphNode => { + const baseNode = createMockLGraphNode({ id }) + return Object.assign(baseNode, { + constructor: { + ...baseNode.constructor, + comfyClass } - ], - remove: vi.fn() + }) + } + + const createMockSubgraph = () => { + const mockNodes = [ + // Mock input node + createMockNode(1, 'SubgraphInputNode'), + // Mock output node + createMockNode(2, 'SubgraphOutputNode'), + // Mock user node + createMockNode(3, 'SomeUserNode'), + // Another mock user node + createMockNode(4, 'AnotherUserNode') + ] + + return { + nodes: mockNodes, + remove: vi.fn(), + events: { + dispatch: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + }, + name: 'test-subgraph', + inputNode: undefined, + outputNode: undefined, + add: vi.fn(), + clear: vi.fn(), + serialize: vi.fn(), + configure: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + runStep: vi.fn(), + findNodeByTitle: vi.fn(), + findNodesByTitle: vi.fn(), + findNodesByType: vi.fn(), + findNodeById: vi.fn(), + getNodeById: vi.fn(), + setDirtyCanvas: vi.fn(), + sendActionToCanvas: vi.fn() + } as Partial as typeof app.canvas.subgraph + } + + const mockSubgraph = createMockSubgraph() + + function createMockSettingStore( + getReturnValue: boolean + ): ReturnType { + return { + get: vi.fn().mockReturnValue(getReturnValue), + addSetting: vi.fn(), + load: vi.fn(), + set: vi.fn(), + exists: vi.fn(), + getDefaultValue: vi.fn(), + isReady: true, + isLoading: false, + error: undefined, + settingValues: {}, + settingsById: {}, + $id: 'setting', + $state: { + settingValues: {}, + settingsById: {}, + isReady: true, + isLoading: false, + error: undefined + }, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set() + } satisfies ReturnType } beforeEach(() => { @@ -142,9 +203,7 @@ describe('useCoreCommands', () => { app.canvas.subgraph = undefined // Mock settings store - vi.mocked(useSettingStore).mockReturnValue({ - get: vi.fn().mockReturnValue(false) // Skip confirmation dialog - } as any) + vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false)) // Mock global confirm global.confirm = vi.fn().mockReturnValue(true) @@ -167,7 +226,7 @@ describe('useCoreCommands', () => { it('should preserve input/output nodes when clearing subgraph', async () => { // Set up subgraph context - app.canvas.subgraph = mockSubgraph as any + app.canvas.subgraph = mockSubgraph const commands = useCoreCommands() const clearCommand = commands.find( @@ -181,24 +240,19 @@ describe('useCoreCommands', () => { expect(app.rootGraph.clear).not.toHaveBeenCalled() // Should only remove user nodes, not input/output nodes - expect(mockSubgraph.remove).toHaveBeenCalledTimes(2) - expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1 - expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2 - expect(mockSubgraph.remove).not.toHaveBeenCalledWith( - mockSubgraph.nodes[0] - ) // input1 - expect(mockSubgraph.remove).not.toHaveBeenCalledWith( - mockSubgraph.nodes[1] - ) // output1 + const subgraph = app.canvas.subgraph! + expect(subgraph.remove).toHaveBeenCalledTimes(2) + expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1 + expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2 + expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1 + expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1 expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared') }) it('should respect confirmation setting', async () => { // Mock confirmation required - vi.mocked(useSettingStore).mockReturnValue({ - get: vi.fn().mockReturnValue(true) // Require confirmation - } as any) + vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true)) global.confirm = vi.fn().mockReturnValue(false) // User cancels diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 6873216aa..319970071 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -67,10 +67,9 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector import { useMaskEditorStore } from '@/stores/maskEditorStore' import { useDialogStore } from '@/stores/dialogStore' -const { isActiveSubscription, showSubscriptionDialog } = useSubscription() - const moveSelectedNodesVersionAdded = '1.22.2' export function useCoreCommands(): ComfyCommand[] { + const { isActiveSubscription, showSubscriptionDialog } = useSubscription() const workflowService = useWorkflowService() const workflowStore = useWorkflowStore() const dialogService = useDialogService() @@ -1235,8 +1234,11 @@ export function useCoreCommands(): ComfyCommand[] { id: 'Comfy.ToggleLinear', icon: 'pi pi-database', label: 'Toggle Simple Mode', - function: () => { + function: (metadata?: Record) => { + const source = + typeof metadata?.source === 'string' ? metadata.source : 'keybind' const newMode = !canvasStore.linearMode + if (newMode) useTelemetry()?.trackEnterLinear({ source }) app.rootGraph.extra.linearMode = newMode workflowStore.activeWorkflow?.changeTracker?.checkState() canvasStore.linearMode = newMode diff --git a/src/composables/useFeatureFlags.test.ts b/src/composables/useFeatureFlags.test.ts index eddb57b65..c2b3634f7 100644 --- a/src/composables/useFeatureFlags.test.ts +++ b/src/composables/useFeatureFlags.test.ts @@ -30,8 +30,7 @@ describe('useFeatureFlags', () => { it('should access supportsPreviewMetadata', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) - return true as any + if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) return true return defaultValue } ) @@ -46,8 +45,7 @@ describe('useFeatureFlags', () => { it('should access maxUploadSize', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) - return 209715200 as any // 200MB + if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200 // 200MB return defaultValue } ) @@ -62,7 +60,7 @@ describe('useFeatureFlags', () => { it('should access supportsManagerV4', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true as any + if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true return defaultValue } ) @@ -76,7 +74,7 @@ describe('useFeatureFlags', () => { it('should return undefined when features are not available and no default provided', () => { vi.mocked(api.getServerFeature).mockImplementation( - (_path, defaultValue) => defaultValue as any + (_path, defaultValue) => defaultValue ) const { flags } = useFeatureFlags() @@ -90,7 +88,7 @@ describe('useFeatureFlags', () => { it('should create reactive computed for custom feature flags', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === 'custom.feature') return 'custom-value' as any + if (path === 'custom.feature') return 'custom-value' return defaultValue } ) @@ -108,7 +106,7 @@ describe('useFeatureFlags', () => { it('should handle nested paths', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === 'extension.custom.nested.feature') return true as any + if (path === 'extension.custom.nested.feature') return true return defaultValue } ) @@ -122,8 +120,7 @@ describe('useFeatureFlags', () => { it('should work with ServerFeatureFlag enum', () => { vi.mocked(api.getServerFeature).mockImplementation( (path, defaultValue) => { - if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) - return 104857600 as any + if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 104857600 return defaultValue } ) diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 136b7ccd1..e0e69b4c9 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -1,6 +1,10 @@ import { computed, reactive, readonly } from 'vue' -import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' +import { isCloud } from '@/platform/distribution/types' +import { + isAuthenticatedConfigLoaded, + remoteConfig +} from '@/platform/remoteConfig/remoteConfig' import { api } from '@/scripts/api' /** @@ -94,7 +98,20 @@ export function useFeatureFlags() { ) ) }, + /** + * Whether team workspaces feature is enabled. + * IMPORTANT: Returns false until authenticated remote config is loaded. + * This ensures we never use workspace tokens when the feature is disabled, + * and prevents race conditions during initialization. + */ get teamWorkspacesEnabled() { + if (!isCloud) return false + + // Only return true if authenticated config has been loaded. + // This prevents race conditions where code checks this flag before + // WorkspaceAuthGate has refreshed the config with auth. + if (!isAuthenticatedConfigLoaded.value) return false + return ( remoteConfig.value.team_workspaces_enabled ?? api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false) diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts new file mode 100644 index 000000000..da637ba0a --- /dev/null +++ b/src/composables/useImageCrop.ts @@ -0,0 +1,469 @@ +import { useResizeObserver } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' + +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { Bounds } from '@/renderer/core/layout/types' +import { app } from '@/scripts/app' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +type ResizeDirection = + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'nw' + | 'ne' + | 'sw' + | 'se' + +const HANDLE_SIZE = 8 +const CORNER_SIZE = 10 +const MIN_CROP_SIZE = 16 +const CROP_BOX_BORDER = 2 + +interface UseImageCropOptions { + imageEl: Ref + containerEl: Ref + modelValue: Ref +} + +export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { + const { imageEl, containerEl, modelValue } = options + const nodeOutputStore = useNodeOutputStore() + + const node = ref(null) + + const imageUrl = ref(null) + const isLoading = ref(false) + + const naturalWidth = ref(0) + const naturalHeight = ref(0) + const displayedWidth = ref(0) + const displayedHeight = ref(0) + const scaleFactor = ref(1) + const imageOffsetX = ref(0) + const imageOffsetY = ref(0) + + const cropX = computed({ + get: () => modelValue.value.x, + set: (v: number) => { + modelValue.value.x = v + } + }) + + const cropY = computed({ + get: () => modelValue.value.y, + set: (v: number) => { + modelValue.value.y = v + } + }) + + const cropWidth = computed({ + get: () => modelValue.value.width || 512, + set: (v: number) => { + modelValue.value.width = v + } + }) + + const cropHeight = computed({ + get: () => modelValue.value.height || 512, + set: (v: number) => { + modelValue.value.height = v + } + }) + + const isDragging = ref(false) + const dragStartX = ref(0) + const dragStartY = ref(0) + const dragStartCropX = ref(0) + const dragStartCropY = ref(0) + + const isResizing = ref(false) + const resizeDirection = ref(null) + const resizeStartX = ref(0) + const resizeStartY = ref(0) + const resizeStartCropX = ref(0) + const resizeStartCropY = ref(0) + const resizeStartCropWidth = ref(0) + const resizeStartCropHeight = ref(0) + + useResizeObserver(containerEl, () => { + if (imageEl.value && imageUrl.value) { + updateDisplayedDimensions() + } + }) + + const getInputImageUrl = (): string | null => { + if (!node.value) return null + + const inputNode = node.value.getInputNode(0) + + if (!inputNode) return null + + const urls = nodeOutputStore.getNodeImageUrls(inputNode) + + if (urls?.length) { + return urls[0] + } + + return null + } + + const updateImageUrl = () => { + imageUrl.value = getInputImageUrl() + } + + const updateDisplayedDimensions = () => { + if (!imageEl.value || !containerEl.value) return + + const img = imageEl.value + const container = containerEl.value + + naturalWidth.value = img.naturalWidth + naturalHeight.value = img.naturalHeight + + if (naturalWidth.value <= 0 || naturalHeight.value <= 0) { + scaleFactor.value = 1 + return + } + + const containerWidth = container.clientWidth + const containerHeight = container.clientHeight + + const imageAspect = naturalWidth.value / naturalHeight.value + const containerAspect = containerWidth / containerHeight + + if (imageAspect > containerAspect) { + displayedWidth.value = containerWidth + displayedHeight.value = containerWidth / imageAspect + imageOffsetX.value = 0 + imageOffsetY.value = (containerHeight - displayedHeight.value) / 2 + } else { + displayedHeight.value = containerHeight + displayedWidth.value = containerHeight * imageAspect + imageOffsetX.value = (containerWidth - displayedWidth.value) / 2 + imageOffsetY.value = 0 + } + + if (naturalWidth.value <= 0 || displayedWidth.value <= 0) { + scaleFactor.value = 1 + } else { + scaleFactor.value = displayedWidth.value / naturalWidth.value + } + } + + const getEffectiveScale = (): number => { + const container = containerEl.value + + if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) { + return 1 + } + + const rect = container.getBoundingClientRect() + const clientWidth = container.clientWidth + + if (!clientWidth || !rect.width) return 1 + + const renderedDisplayedWidth = + (displayedWidth.value / clientWidth) * rect.width + + return renderedDisplayedWidth / naturalWidth.value + } + + const cropBoxStyle = computed(() => ({ + left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`, + top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}px`, + width: `${cropWidth.value * scaleFactor.value}px`, + height: `${cropHeight.value * scaleFactor.value}px` + })) + + const cropImageStyle = computed(() => { + if (!imageUrl.value) return {} + + return { + backgroundImage: `url(${imageUrl.value})`, + backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`, + backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`, + backgroundRepeat: 'no-repeat' + } + }) + + interface ResizeHandle { + direction: ResizeDirection + class: string + style: { + left: string + top: string + width?: string + height?: string + } + } + + const resizeHandles = computed(() => { + const x = imageOffsetX.value + cropX.value * scaleFactor.value + const y = imageOffsetY.value + cropY.value * scaleFactor.value + const w = cropWidth.value * scaleFactor.value + const h = cropHeight.value * scaleFactor.value + + return [ + { + direction: 'top', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'bottom', + class: 'h-2 cursor-ns-resize', + style: { + left: `${x + HANDLE_SIZE}px`, + top: `${y + h - HANDLE_SIZE / 2}px`, + width: `${Math.max(0, w - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'left', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'right', + class: 'w-2 cursor-ew-resize', + style: { + left: `${x + w - HANDLE_SIZE / 2}px`, + top: `${y + HANDLE_SIZE}px`, + height: `${Math.max(0, h - HANDLE_SIZE * 2)}px` + } + }, + { + direction: 'nw', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'ne', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'sw', + class: 'cursor-nesw-resize rounded-sm bg-white/80', + style: { + left: `${x - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + }, + { + direction: 'se', + class: 'cursor-nwse-resize rounded-sm bg-white/80', + style: { + left: `${x + w - CORNER_SIZE / 2}px`, + top: `${y + h - CORNER_SIZE / 2}px`, + width: `${CORNER_SIZE}px`, + height: `${CORNER_SIZE}px` + } + } + ] + }) + + const handleImageLoad = () => { + isLoading.value = false + updateDisplayedDimensions() + } + + const handleImageError = () => { + isLoading.value = false + imageUrl.value = null + } + + const capturePointer = (e: PointerEvent) => + (e.target as HTMLElement).setPointerCapture(e.pointerId) + + const releasePointer = (e: PointerEvent) => + (e.target as HTMLElement).releasePointerCapture(e.pointerId) + + const handleDragStart = (e: PointerEvent) => { + if (!imageUrl.value) return + + isDragging.value = true + dragStartX.value = e.clientX + dragStartY.value = e.clientY + dragStartCropX.value = cropX.value + dragStartCropY.value = cropY.value + capturePointer(e) + } + + const handleDragMove = (e: PointerEvent) => { + if (!isDragging.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const deltaX = (e.clientX - dragStartX.value) / effectiveScale + const deltaY = (e.clientY - dragStartY.value) / effectiveScale + + const maxX = naturalWidth.value - cropWidth.value + const maxY = naturalHeight.value - cropHeight.value + + cropX.value = Math.round( + Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX)) + ) + cropY.value = Math.round( + Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY)) + ) + } + + const handleDragEnd = (e: PointerEvent) => { + if (!isDragging.value) return + + isDragging.value = false + releasePointer(e) + } + + const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => { + if (!imageUrl.value) return + + e.stopPropagation() + isResizing.value = true + resizeDirection.value = direction + + resizeStartX.value = e.clientX + resizeStartY.value = e.clientY + resizeStartCropX.value = cropX.value + resizeStartCropY.value = cropY.value + resizeStartCropWidth.value = cropWidth.value + resizeStartCropHeight.value = cropHeight.value + capturePointer(e) + } + + const handleResizeMove = (e: PointerEvent) => { + if (!isResizing.value || !resizeDirection.value) return + + const effectiveScale = getEffectiveScale() + if (effectiveScale === 0) return + + const dir = resizeDirection.value + const deltaX = (e.clientX - resizeStartX.value) / effectiveScale + const deltaY = (e.clientY - resizeStartY.value) / effectiveScale + + const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw' + const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se' + const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne' + const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se' + + let newX = resizeStartCropX.value + let newY = resizeStartCropY.value + let newWidth = resizeStartCropWidth.value + let newHeight = resizeStartCropHeight.value + + if (affectsLeft) { + const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE + const minDeltaX = -resizeStartCropX.value + const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX)) + newX = resizeStartCropX.value + clampedDeltaX + newWidth = resizeStartCropWidth.value - clampedDeltaX + } else if (affectsRight) { + const maxWidth = naturalWidth.value - resizeStartCropX.value + newWidth = Math.max( + MIN_CROP_SIZE, + Math.min(maxWidth, resizeStartCropWidth.value + deltaX) + ) + } + + if (affectsTop) { + const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE + const minDeltaY = -resizeStartCropY.value + const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY)) + newY = resizeStartCropY.value + clampedDeltaY + newHeight = resizeStartCropHeight.value - clampedDeltaY + } else if (affectsBottom) { + const maxHeight = naturalHeight.value - resizeStartCropY.value + newHeight = Math.max( + MIN_CROP_SIZE, + Math.min(maxHeight, resizeStartCropHeight.value + deltaY) + ) + } + + if (affectsLeft || affectsRight) { + cropX.value = Math.round(newX) + cropWidth.value = Math.round(newWidth) + } + if (affectsTop || affectsBottom) { + cropY.value = Math.round(newY) + cropHeight.value = Math.round(newHeight) + } + } + + const handleResizeEnd = (e: PointerEvent) => { + if (!isResizing.value) return + + isResizing.value = false + resizeDirection.value = null + releasePointer(e) + } + + const initialize = () => { + if (nodeId != null) { + node.value = app.rootGraph?.getNodeById(nodeId) || null + } + + updateImageUrl() + } + + watch( + () => nodeOutputStore.nodeOutputs, + () => updateImageUrl(), + { deep: true } + ) + + watch( + () => nodeOutputStore.nodePreviewImages, + () => updateImageUrl(), + { deep: true } + ) + + onMounted(initialize) + + return { + imageUrl, + isLoading, + + cropX, + cropY, + cropWidth, + cropHeight, + + cropBoxStyle, + cropImageStyle, + resizeHandles, + + handleImageLoad, + handleImageError, + handleDragStart, + handleDragMove, + handleDragEnd, + handleResizeStart, + handleResizeMove, + handleResizeEnd + } +} diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 833e95efd..dafc19931 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -1,11 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref } from 'vue' +import { nextTick, ref, shallowRef } from 'vue' import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import type { Size } from '@/lib/litegraph/src/interfaces' +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { IWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '@/scripts/api' +import { + createMockCanvasPointerEvent, + createMockLGraphNode +} from '@/utils/__tests__/litegraphTestUtils' vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: vi.fn() @@ -36,15 +44,15 @@ vi.mock('@/i18n', () => ({ })) describe('useLoad3d', () => { - let mockLoad3d: any - let mockNode: any - let mockToastStore: any + let mockLoad3d: Partial + let mockNode: LGraphNode + let mockToastStore: ReturnType beforeEach(() => { vi.clearAllMocks() nodeToLoad3dMap.clear() - mockNode = { + mockNode = createMockLGraphNode({ properties: { 'Scene Config': { showGrid: true, @@ -68,18 +76,21 @@ describe('useLoad3d', () => { 'Resource Folder': '' }, widgets: [ - { name: 'width', value: 512 }, - { name: 'height', value: 512 } + { name: 'width', value: 512, type: 'number' } as IWidget, + { name: 'height', value: 512, type: 'number' } as IWidget ], graph: { setDirtyCanvas: vi.fn() - }, + } as Partial as LGraph, flags: {}, - onMouseEnter: null, - onMouseLeave: null, - onResize: null, - onDrawBackground: null - } + onMouseEnter: undefined, + onMouseLeave: undefined, + onResize: undefined, + onDrawBackground: undefined + }) + + const mockCanvas = document.createElement('canvas') + mockCanvas.hidden = false mockLoad3d = { toggleGrid: vi.fn(), @@ -114,19 +125,20 @@ describe('useLoad3d', () => { removeEventListener: vi.fn(), remove: vi.fn(), renderer: { - domElement: { - hidden: false - } - } + domElement: mockCanvas + } as Partial as Load3d['renderer'] } - vi.mocked(Load3d).mockImplementation(function () { + vi.mocked(Load3d).mockImplementation(function (this: Load3d) { Object.assign(this, mockLoad3d) + return this }) mockToastStore = { addAlert: vi.fn() - } + } as Partial> as ReturnType< + typeof useToastStore + > vi.mocked(useToastStore).mockReturnValue(mockToastStore) }) @@ -208,14 +220,14 @@ describe('useLoad3d', () => { expect(mockNode.onDrawBackground).toBeDefined() // Test the handlers - mockNode.onMouseEnter() + mockNode.onMouseEnter?.(createMockCanvasPointerEvent(0, 0)) expect(mockLoad3d.refreshViewport).toHaveBeenCalled() expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true) - mockNode.onMouseLeave() + mockNode.onMouseLeave?.(createMockCanvasPointerEvent(0, 0)) expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false) - mockNode.onResize() + mockNode.onResize?.([512, 512] as Size) expect(mockLoad3d.handleResize).toHaveBeenCalled() }) @@ -226,13 +238,17 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) mockNode.flags.collapsed = true - mockNode.onDrawBackground() + mockNode.onDrawBackground?.({} as CanvasRenderingContext2D) - expect(mockLoad3d.renderer.domElement.hidden).toBe(true) + expect(mockLoad3d.renderer!.domElement.hidden).toBe(true) }) it('should load model if model_file widget exists', async () => { - mockNode.widgets.push({ name: 'model_file', value: 'test.glb' }) + mockNode.widgets!.push({ + name: 'model_file', + value: 'test.glb', + type: 'text' + } as IWidget) vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ 'subfolder', 'test.glb' @@ -255,8 +271,12 @@ describe('useLoad3d', () => { }) it('should restore camera state after loading model', async () => { - mockNode.widgets.push({ name: 'model_file', value: 'test.glb' }) - mockNode.properties['Camera Config'].state = { + mockNode.widgets!.push({ + name: 'model_file', + value: 'test.glb', + type: 'text' + } as IWidget) + ;(mockNode.properties!['Camera Config'] as { state: unknown }).state = { position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 } } @@ -312,13 +332,13 @@ describe('useLoad3d', () => { it('should handle missing container or node', async () => { const composable = useLoad3d(mockNode) - await composable.initializeLoad3d(null as any) + await composable.initializeLoad3d(null!) expect(Load3d).not.toHaveBeenCalled() }) it('should accept ref as parameter', () => { - const nodeRef = ref(mockNode) + const nodeRef = shallowRef(mockNode) const composable = useLoad3d(nodeRef) expect(composable.sceneConfig.value.backgroundColor).toBe('#000000') @@ -370,9 +390,9 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) - mockLoad3d.toggleGrid.mockClear() - mockLoad3d.setBackgroundColor.mockClear() - mockLoad3d.setBackgroundImage.mockClear() + vi.mocked(mockLoad3d.toggleGrid!).mockClear() + vi.mocked(mockLoad3d.setBackgroundColor!).mockClear() + vi.mocked(mockLoad3d.setBackgroundImage!).mockClear() composable.sceneConfig.value = { showGrid: false, @@ -403,8 +423,8 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) await nextTick() - mockLoad3d.setUpDirection.mockClear() - mockLoad3d.setMaterialMode.mockClear() + vi.mocked(mockLoad3d.setUpDirection!).mockClear() + vi.mocked(mockLoad3d.setMaterialMode!).mockClear() composable.modelConfig.value.upDirection = '+y' composable.modelConfig.value.materialMode = 'wireframe' @@ -426,8 +446,8 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) await nextTick() - mockLoad3d.toggleCamera.mockClear() - mockLoad3d.setFOV.mockClear() + vi.mocked(mockLoad3d.toggleCamera!).mockClear() + vi.mocked(mockLoad3d.setFOV!).mockClear() composable.cameraConfig.value.cameraType = 'orthographic' composable.cameraConfig.value.fov = 90 @@ -449,7 +469,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) await nextTick() - mockLoad3d.setLightIntensity.mockClear() + vi.mocked(mockLoad3d.setLightIntensity!).mockClear() composable.lightConfig.value.intensity = 10 await nextTick() @@ -589,7 +609,7 @@ describe('useLoad3d', () => { }) it('should use resource folder for upload', async () => { - mockNode.properties['Resource Folder'] = 'subfolder' + mockNode.properties!['Resource Folder'] = 'subfolder' vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg') const composable = useLoad3d(mockNode) @@ -641,7 +661,9 @@ describe('useLoad3d', () => { }) it('should handle export errors', async () => { - mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed')) + vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce( + new Error('Export failed') + ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -719,12 +741,12 @@ describe('useLoad3d', () => { }) it('should handle materialModeChange event', async () => { - let materialModeHandler: any + let materialModeHandler: ((mode: string) => void) | undefined - mockLoad3d.addEventListener.mockImplementation( - (event: string, handler: any) => { + vi.mocked(mockLoad3d.addEventListener!).mockImplementation( + (event: string, handler: unknown) => { if (event === 'materialModeChange') { - materialModeHandler = handler + materialModeHandler = handler as (mode: string) => void } } ) @@ -734,21 +756,21 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) - materialModeHandler('wireframe') + materialModeHandler?.('wireframe') expect(composable.modelConfig.value.materialMode).toBe('wireframe') }) it('should handle loading events', async () => { - let modelLoadingStartHandler: any - let modelLoadingEndHandler: any + let modelLoadingStartHandler: (() => void) | undefined + let modelLoadingEndHandler: (() => void) | undefined - mockLoad3d.addEventListener.mockImplementation( - (event: string, handler: any) => { + vi.mocked(mockLoad3d.addEventListener!).mockImplementation( + (event: string, handler: unknown) => { if (event === 'modelLoadingStart') { - modelLoadingStartHandler = handler + modelLoadingStartHandler = handler as () => void } else if (event === 'modelLoadingEnd') { - modelLoadingEndHandler = handler + modelLoadingEndHandler = handler as () => void } } ) @@ -758,22 +780,22 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) - modelLoadingStartHandler() + modelLoadingStartHandler?.() expect(composable.loading.value).toBe(true) expect(composable.loadingMessage.value).toBe('load3d.loadingModel') - modelLoadingEndHandler() + modelLoadingEndHandler?.() expect(composable.loading.value).toBe(false) expect(composable.loadingMessage.value).toBe('') }) it('should handle recordingStatusChange event', async () => { - let recordingStatusHandler: any + let recordingStatusHandler: ((status: boolean) => void) | undefined - mockLoad3d.addEventListener.mockImplementation( - (event: string, handler: any) => { + vi.mocked(mockLoad3d.addEventListener!).mockImplementation( + (event: string, handler: unknown) => { if (event === 'recordingStatusChange') { - recordingStatusHandler = handler + recordingStatusHandler = handler as (status: boolean) => void } } ) @@ -783,7 +805,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) - recordingStatusHandler(false) + recordingStatusHandler?.(false) expect(composable.isRecording.value).toBe(false) expect(composable.recordingDuration.value).toBe(10) @@ -814,10 +836,11 @@ describe('useLoad3d', () => { describe('getModelUrl', () => { it('should handle http URLs directly', async () => { - mockNode.widgets.push({ + mockNode.widgets!.push({ name: 'model_file', - value: 'http://example.com/model.glb' - }) + value: 'http://example.com/model.glb', + type: 'text' + } as IWidget) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -830,7 +853,11 @@ describe('useLoad3d', () => { }) it('should construct URL for local files', async () => { - mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' }) + mockNode.widgets!.push({ + name: 'model_file', + value: 'models/test.glb', + type: 'text' + } as IWidget) vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ 'models', 'test.glb' @@ -860,7 +887,9 @@ describe('useLoad3d', () => { }) it('should use output type for preview mode', async () => { - mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets + mockNode.widgets = [ + { name: 'model_file', value: 'test.glb', type: 'text' } as IWidget + ] // No width/height widgets vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb']) vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( '/api/view/test.glb' @@ -894,10 +923,10 @@ describe('useLoad3d', () => { }) it('should handle missing configurations', async () => { - delete mockNode.properties['Scene Config'] - delete mockNode.properties['Model Config'] - delete mockNode.properties['Camera Config'] - delete mockNode.properties['Light Config'] + delete mockNode.properties!['Scene Config'] + delete mockNode.properties!['Model Config'] + delete mockNode.properties!['Camera Config'] + delete mockNode.properties!['Light Config'] const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -909,7 +938,11 @@ describe('useLoad3d', () => { }) it('should handle background image with existing config', async () => { - mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg' + ;( + mockNode.properties!['Scene Config'] as { + backgroundImage: string + } + ).backgroundImage = 'existing.jpg' const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index c8312051a..b07daba2e 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { hasSkeleton.value = load3d?.hasSkeleton() ?? false // Reset skeleton visibility when loading new model modelConfig.value.showSkeleton = false + + if (load3d) { + const node = nodeRef.value + + const modelWidget = node?.widgets?.find( + (w) => w.name === 'model_file' || w.name === 'image' + ) + const value = modelWidget?.value + if (typeof value === 'string') { + void Load3dUtils.generateThumbnailIfNeeded( + load3d, + value, + isPreview.value ? 'output' : 'input' + ) + } + } }, skeletonVisibilityChange: (value: boolean) => { modelConfig.value.showSkeleton = value diff --git a/src/composables/useLoad3dDrag.test.ts b/src/composables/useLoad3dDrag.test.ts index f35c5c736..a682f5af8 100644 --- a/src/composables/useLoad3dDrag.test.ts +++ b/src/composables/useLoad3dDrag.test.ts @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useLoad3dDrag } from '@/composables/useLoad3dDrag' import { useToastStore } from '@/platform/updates/common/toastStore' +import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils' vi.mock('@/platform/updates/common/toastStore', () => ({ useToastStore: vi.fn() @@ -19,22 +20,22 @@ function createMockDragEvent( const files = options.files || [] const types = options.hasFiles ? ['Files'] : [] - const dataTransfer = { + const dataTransfer: Partial = { types, - files, + files: createMockFileList(files), dropEffect: 'none' as DataTransfer['dropEffect'] } - const event = { + const event: Partial = { type, - dataTransfer - } as unknown as DragEvent + dataTransfer: dataTransfer as DataTransfer + } - return event + return event as DragEvent } describe('useLoad3dDrag', () => { - let mockToastStore: any + let mockToastStore: ReturnType let mockOnModelDrop: (file: File) => void | Promise beforeEach(() => { @@ -42,7 +43,9 @@ describe('useLoad3dDrag', () => { mockToastStore = { addAlert: vi.fn() - } + } as Partial> as ReturnType< + typeof useToastStore + > vi.mocked(useToastStore).mockReturnValue(mockToastStore) mockOnModelDrop = vi.fn() diff --git a/src/composables/useLoad3dViewer.test.ts b/src/composables/useLoad3dViewer.test.ts index 77a8e50a0..757e1400d 100644 --- a/src/composables/useLoad3dViewer.test.ts +++ b/src/composables/useLoad3dViewer.test.ts @@ -4,8 +4,11 @@ import { nextTick } from 'vue' import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useToastStore } from '@/platform/updates/common/toastStore' import { useLoad3dService } from '@/services/load3dService' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' vi.mock('@/services/load3dService', () => ({ useLoad3dService: vi.fn() @@ -29,17 +32,32 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: vi.fn() })) +function createMockSceneManager(): Load3d['sceneManager'] { + const mock: Partial = { + scene: {} as Load3d['sceneManager']['scene'], + backgroundScene: {} as Load3d['sceneManager']['backgroundScene'], + backgroundCamera: {} as Load3d['sceneManager']['backgroundCamera'], + currentBackgroundColor: '#282828', + gridHelper: { visible: true } as Load3d['sceneManager']['gridHelper'], + getCurrentBackgroundInfo: vi.fn().mockReturnValue({ + type: 'color', + value: '#282828' + }) + } + return mock as Load3d['sceneManager'] +} + describe('useLoad3dViewer', () => { - let mockLoad3d: any - let mockSourceLoad3d: any - let mockLoad3dService: any - let mockToastStore: any - let mockNode: any + let mockLoad3d: Partial + let mockSourceLoad3d: Partial + let mockLoad3dService: ReturnType + let mockToastStore: ReturnType + let mockNode: LGraphNode beforeEach(() => { vi.clearAllMocks() - mockNode = { + mockNode = createMockLGraphNode({ properties: { 'Scene Config': { backgroundColor: '#282828', @@ -62,9 +80,9 @@ describe('useLoad3dViewer', () => { }, graph: { setDirtyCanvas: vi.fn() - }, + } as Partial as LGraph, widgets: [] - } as any + }) mockLoad3d = { setBackgroundColor: vi.fn(), @@ -97,24 +115,17 @@ describe('useLoad3dViewer', () => { zoom: 1, cameraType: 'perspective' }), - sceneManager: { - currentBackgroundColor: '#282828', - gridHelper: { visible: true }, - getCurrentBackgroundInfo: vi.fn().mockReturnValue({ - type: 'color', - value: '#282828' - }) - }, + sceneManager: createMockSceneManager(), lightingManager: { lights: [null, { intensity: 1 }] - }, + } as Load3d['lightingManager'], cameraManager: { perspectiveCamera: { fov: 75 } - }, + } as Load3d['cameraManager'], modelManager: { currentUpDirection: 'original', materialMode: 'original' - }, + } as Load3d['modelManager'], setBackgroundImage: vi.fn().mockResolvedValue(undefined), setBackgroundRenderMode: vi.fn(), forceRender: vi.fn() @@ -128,12 +139,16 @@ describe('useLoad3dViewer', () => { copyLoad3dState: vi.fn().mockResolvedValue(undefined), handleViewportRefresh: vi.fn(), getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d) - } + } as Partial> as ReturnType< + typeof useLoad3dService + > vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService) mockToastStore = { addAlert: vi.fn() - } + } as Partial> as ReturnType< + typeof useToastStore + > vi.mocked(useToastStore).mockReturnValue(mockToastStore) }) @@ -160,7 +175,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(Load3d).toHaveBeenCalledWith(containerRef, { width: undefined, @@ -184,16 +199,20 @@ describe('useLoad3dViewer', () => { }) it('should handle background image during initialization', async () => { - mockSourceLoad3d.sceneManager.getCurrentBackgroundInfo.mockReturnValue({ + vi.mocked( + mockSourceLoad3d.sceneManager!.getCurrentBackgroundInfo + ).mockReturnValue({ type: 'image', value: '' }) - mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg' + ;( + mockNode.properties!['Scene Config'] as Record + ).backgroundImage = 'test-image.jpg' const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.backgroundImage.value).toBe('test-image.jpg') expect(viewer.hasBackgroundImage.value).toBe(true) @@ -207,7 +226,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.failedToInitializeLoad3dViewer' @@ -220,7 +239,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundColor.value = '#ff0000' await nextTick() @@ -232,7 +251,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.showGrid.value = false await nextTick() @@ -244,7 +263,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.cameraType.value = 'orthographic' await nextTick() @@ -256,7 +275,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.fov.value = 90 await nextTick() @@ -268,7 +287,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.lightIntensity.value = 2 await nextTick() @@ -280,7 +299,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundImage.value = 'new-bg.jpg' await nextTick() @@ -293,7 +312,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.upDirection.value = '+y' await nextTick() @@ -305,7 +324,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.materialMode.value = 'wireframe' await nextTick() @@ -314,14 +333,16 @@ describe('useLoad3dViewer', () => { }) it('should handle watcher errors gracefully', async () => { - mockLoad3d.setBackgroundColor.mockImplementationOnce(function () { - throw new Error('Color update failed') - }) + vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce( + function () { + throw new Error('Color update failed') + } + ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundColor.value = '#ff0000' await nextTick() @@ -337,7 +358,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) await viewer.exportModel('glb') @@ -345,12 +366,14 @@ describe('useLoad3dViewer', () => { }) it('should handle export errors', async () => { - mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed')) + vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce( + new Error('Export failed') + ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) await viewer.exportModel('glb') @@ -373,7 +396,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleResize() @@ -384,7 +407,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleMouseEnter() @@ -395,7 +418,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleMouseLeave() @@ -408,22 +431,35 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) - - mockNode.properties['Scene Config'].backgroundColor = '#ff0000' - mockNode.properties['Scene Config'].showGrid = false + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + ;( + mockNode.properties!['Scene Config'] as Record + ).backgroundColor = '#ff0000' + ;( + mockNode.properties!['Scene Config'] as Record + ).showGrid = false viewer.restoreInitialState() - expect(mockNode.properties['Scene Config'].backgroundColor).toBe( - '#282828' - ) - expect(mockNode.properties['Scene Config'].showGrid).toBe(true) - expect(mockNode.properties['Camera Config'].cameraType).toBe( - 'perspective' - ) - expect(mockNode.properties['Camera Config'].fov).toBe(75) - expect(mockNode.properties['Light Config'].intensity).toBe(1) + expect( + (mockNode.properties!['Scene Config'] as Record) + .backgroundColor + ).toBe('#282828') + expect( + (mockNode.properties!['Scene Config'] as Record) + .showGrid + ).toBe(true) + expect( + (mockNode.properties!['Camera Config'] as Record) + .cameraType + ).toBe('perspective') + expect( + (mockNode.properties!['Camera Config'] as Record).fov + ).toBe(75) + expect( + (mockNode.properties!['Light Config'] as Record) + .intensity + ).toBe(1) }) }) @@ -432,7 +468,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundColor.value = '#ff0000' viewer.showGrid.value = false @@ -440,23 +476,27 @@ describe('useLoad3dViewer', () => { const result = await viewer.applyChanges() expect(result).toBe(true) - expect(mockNode.properties['Scene Config'].backgroundColor).toBe( - '#ff0000' - ) - expect(mockNode.properties['Scene Config'].showGrid).toBe(false) + expect( + (mockNode.properties!['Scene Config'] as Record) + .backgroundColor + ).toBe('#ff0000') + expect( + (mockNode.properties!['Scene Config'] as Record) + .showGrid + ).toBe(false) expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith( mockLoad3d, mockSourceLoad3d ) expect(mockSourceLoad3d.forceRender).toHaveBeenCalled() - expect(mockNode.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true) + expect(mockNode.graph!.setDirtyCanvas).toHaveBeenCalledWith(true, true) }) it('should handle background image during apply', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundImage.value = 'new-bg.jpg' @@ -481,7 +521,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.refreshViewport() @@ -498,7 +538,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) @@ -515,7 +555,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) @@ -527,7 +567,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundImage.value = 'existing.jpg' viewer.hasBackgroundImage.value = true @@ -546,7 +586,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) @@ -562,7 +602,7 @@ describe('useLoad3dViewer', () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.cleanup() @@ -580,33 +620,36 @@ describe('useLoad3dViewer', () => { it('should handle missing container ref', async () => { const viewer = useLoad3dViewer(mockNode) - await viewer.initializeViewer(null as any, mockSourceLoad3d) + await viewer.initializeViewer(null!, mockSourceLoad3d as Load3d) expect(Load3d).not.toHaveBeenCalled() }) it('should handle orthographic camera', async () => { - mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic') + vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue( + 'orthographic' + ) mockSourceLoad3d.cameraManager = { perspectiveCamera: { fov: 75 } - } - delete mockNode.properties['Camera Config'].cameraType + } as Partial as Load3d['cameraManager'] + delete (mockNode.properties!['Camera Config'] as Record) + .cameraType const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.cameraType.value).toBe('orthographic') }) it('should handle missing lights', async () => { - mockSourceLoad3d.lightingManager.lights = [] + mockSourceLoad3d.lightingManager!.lights = [] const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') - await viewer.initializeViewer(containerRef, mockSourceLoad3d) + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.lightIntensity.value).toBe(1) // Default value }) diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index 38b4a9f54..94e4ebc24 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -630,6 +630,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => { handleBackgroundImageUpdate, handleModelDrop, handleSeek, - cleanup + cleanup, + + hasSkeleton: false, + intensity: lightIntensity, + showSkeleton: false } } diff --git a/src/composables/useNodeHelpContent.test.ts b/src/composables/useNodeHelpContent.test.ts new file mode 100644 index 000000000..02b3d102f --- /dev/null +++ b/src/composables/useNodeHelpContent.test.ts @@ -0,0 +1,381 @@ +import { flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import { useNodeHelpContent } from '@/composables/useNodeHelpContent' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' + +function createMockNode( + overrides: Partial +): ComfyNodeDefImpl { + return { + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + category: 'test', + python_module: 'comfy.test_node', + inputs: {}, + outputs: [], + deprecated: false, + experimental: false, + output_node: false, + api_node: false, + ...overrides + } as ComfyNodeDefImpl +} + +vi.mock('@/scripts/api', () => ({ + api: { + fileURL: vi.fn((url) => url) + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + locale: ref('en') + }) +})) + +vi.mock('@/types/nodeSource', () => ({ + NodeSourceType: { + Core: 'core', + CustomNodes: 'custom_nodes' + }, + getNodeSource: vi.fn((pythonModule) => { + if (pythonModule?.startsWith('custom_nodes.')) { + return { type: 'custom_nodes' } + } + return { type: 'core' } + }) +})) + +describe('useNodeHelpContent', () => { + const mockCoreNode = createMockNode({ + name: 'TestNode', + display_name: 'Test Node', + description: 'A test node', + python_module: 'comfy.test_node' + }) + + const mockCustomNode = createMockNode({ + name: 'CustomNode', + display_name: 'Custom Node', + description: 'A custom node', + python_module: 'custom_nodes.test_module.custom@1.0.0' + }) + + const mockFetch = vi.fn() + + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should generate correct baseUrl for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`) + }) + + it('should generate correct baseUrl for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test' + }) + + const { baseUrl } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(baseUrl.value).toBe('/extensions/test_module/docs/') + }) + + it('should render markdown content correctly', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test Help\nThis is test help content' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('This is test help content') + }) + + it('should handle fetch errors and fall back to description', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + + const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(error.value).toBe('Not Found') + expect(renderedHelpHtml.value).toContain(mockCoreNode.description) + }) + + it('should include alt attribute for images', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '![image](test.jpg)' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('alt="image"') + }) + + it('should prefix relative video src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative video src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.mp4"` + ) + }) + + it('should handle loading state', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves + + const { isLoading } = useNodeHelpContent(nodeRef) + await nextTick() + + expect(isLoading.value).toBe(true) + }) + + it('should try fallback URL for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch + .mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Fallback content' + }) + + useNodeHelpContent(nodeRef) + await flushPromises() + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode/en.md' + ) + expect(mockFetch).toHaveBeenCalledWith( + '/extensions/test_module/docs/CustomNode.md' + ) + }) + + it('should prefix relative source src in custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/video.mp4"' + ) + }) + + it('should prefix relative source src for core nodes with node-specific base URL', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + '' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video.webm"` + ) + }) + + it('should prefix relative img src in raw HTML for custom nodes', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="/extensions/test_module/docs/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should prefix relative img src in raw HTML for core nodes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => '# Test\nTest image' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image.png"` + ) + expect(renderedHelpHtml.value).toContain('alt="Test image"') + }) + + it('should not prefix absolute img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => 'Absolute' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"') + expect(renderedHelpHtml.value).toContain('alt="Absolute"') + }) + + it('should not prefix external img src in raw HTML', async () => { + const nodeRef = ref(mockCustomNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => + 'External' + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + expect(renderedHelpHtml.value).toContain( + 'src="https://example.com/image.png"' + ) + expect(renderedHelpHtml.value).toContain('alt="External"') + }) + + it('should handle various quote styles in media src attributes', async () => { + const nodeRef = ref(mockCoreNode) + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => `# Media Test + +Testing quote styles in properly formed HTML: + + + +Double quotes +Single quotes + + + +The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.` + }) + + const { renderedHelpHtml } = useNodeHelpContent(nodeRef) + await flushPromises() + + // All media src attributes should be prefixed correctly + // Note: marked normalizes quotes to double quotes in output + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video1.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video2.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image1.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/image2.png"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.mp4"` + ) + expect(renderedHelpHtml.value).toContain( + `src="/docs/${mockCoreNode.name}/video3.webm"` + ) + }) + + it('should ignore stale requests when node changes', async () => { + const nodeRef = ref(mockCoreNode) + let resolveFirst: (value: unknown) => void + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + + mockFetch + .mockImplementationOnce(() => firstRequest) + .mockResolvedValueOnce({ + ok: true, + text: async () => '# Second node content' + }) + + const { helpContent } = useNodeHelpContent(nodeRef) + await nextTick() + + // Change node before first request completes + nodeRef.value = mockCustomNode + await nextTick() + await flushPromises() + + // Now resolve the first (stale) request + resolveFirst!({ + ok: true, + text: async () => '# First node content' + }) + await flushPromises() + + // Should have second node's content, not first + expect(helpContent.value).toBe('# Second node content') + }) +}) diff --git a/src/composables/useNodeHelpContent.ts b/src/composables/useNodeHelpContent.ts new file mode 100644 index 000000000..81ef9ae47 --- /dev/null +++ b/src/composables/useNodeHelpContent.ts @@ -0,0 +1,79 @@ +import type { MaybeRefOrGetter } from 'vue' +import { computed, ref, toValue, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +import { nodeHelpService } from '@/services/nodeHelpService' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' +import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil' + +/** + * Composable for fetching and rendering node help content. + * Creates independent state for each usage, allowing multiple panels + * to show help content without interfering with each other. + * + * @param nodeRef - Reactive reference to the node to show help for + * @returns Reactive help content state and rendered HTML + */ +export function useNodeHelpContent( + nodeRef: MaybeRefOrGetter +) { + const { locale } = useI18n() + + const helpContent = ref('') + const isLoading = ref(false) + const error = ref(null) + + let currentRequest: Promise | null = null + + const baseUrl = computed(() => { + const node = toValue(nodeRef) + if (!node) return '' + return getNodeHelpBaseUrl(node) + }) + + const renderedHelpHtml = computed(() => { + return renderMarkdownToHtml(helpContent.value, baseUrl.value) + }) + + // Watch for node changes and fetch help content + watch( + () => toValue(nodeRef), + async (node) => { + helpContent.value = '' + error.value = null + + if (node) { + isLoading.value = true + const request = (currentRequest = nodeHelpService.fetchNodeHelp( + node, + locale.value || 'en' + )) + + try { + const content = await request + if (currentRequest !== request) return + helpContent.value = content + } catch (e: unknown) { + if (currentRequest !== request) return + error.value = e instanceof Error ? e.message : String(e) + helpContent.value = node.description || '' + } finally { + if (currentRequest === request) { + currentRequest = null + isLoading.value = false + } + } + } + }, + { immediate: true } + ) + + return { + helpContent, + isLoading, + error, + baseUrl, + renderedHelpHtml + } +} diff --git a/src/composables/useTemplateFiltering.test.ts b/src/composables/useTemplateFiltering.test.ts index f6e617cb7..a44db0e99 100644 --- a/src/composables/useTemplateFiltering.test.ts +++ b/src/composables/useTemplateFiltering.test.ts @@ -395,4 +395,87 @@ describe('useTemplateFiltering', () => { expect(mockGetFuseOptions).toHaveBeenCalledTimes(1) }) }) + + describe('Scope-aware filtering', () => { + it('filters out inactive models when scope changes', () => { + // Start with image templates only + const templates = ref([ + { + name: 'flux-template', + description: 'Flux model template', + models: ['Flux', 'Dall-E'], + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const currentScope = ref('image') + + const { + selectedModels, + activeModels, + inactiveModels, + filteredTemplates + } = useTemplateFiltering(templates, currentScope) + + // Select models from both image and video domains + selectedModels.value = ['Flux', 'Luma'] + + // In image scope, only Flux should be active because Luma doesn't exist in any image template + expect(activeModels.value).toEqual(['Flux']) + expect(inactiveModels.value).toEqual(['Luma']) + expect(filteredTemplates.value).toHaveLength(1) + expect(filteredTemplates.value[0].name).toBe('flux-template') + + // Switch to video scope with only video templates + currentScope.value = 'video' + templates.value = [ + { + name: 'luma-template', + description: 'Luma video template', + models: ['Luma', 'Runway'], + mediaType: 'video', + mediaSubtype: 'mp4' + } + ] + + // In video scope, only Luma should be active because Flux doesn't exist in any video template + expect(activeModels.value).toEqual(['Luma']) + expect(inactiveModels.value).toEqual(['Flux']) + expect(filteredTemplates.value).toHaveLength(1) + expect(filteredTemplates.value[0].name).toBe('luma-template') + }) + + it('maintains selected filters across scope changes', () => { + const templates = ref([ + { + name: 'template1', + description: 'Template 1', + models: ['Model1'], + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const currentScope = ref('image') + const { selectedModels, activeModels } = useTemplateFiltering( + templates, + currentScope + ) + + // Select a model + selectedModels.value = ['Model1', 'Model2'] + + // Model1 is active, Model2 is not available + expect(activeModels.value).toEqual(['Model1']) + expect(selectedModels.value).toEqual(['Model1', 'Model2']) + + // Change scope - selected models should persist + currentScope.value = 'video' + templates.value = [] + + expect(selectedModels.value).toEqual(['Model1', 'Model2']) + expect(activeModels.value).toEqual([]) + }) + }) }) diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index 9dcdeffcc..c29d6cadd 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -1,4 +1,4 @@ -import { refDebounced, watchDebounced } from '@vueuse/core' +import { refThrottled, watchDebounced } from '@vueuse/core' import Fuse from 'fuse.js' import type { IFuseOptions } from 'fuse.js' import { computed, ref, watch } from 'vue' @@ -26,7 +26,8 @@ const defaultFuseOptions: IFuseOptions = { } export function useTemplateFiltering( - templates: Ref | TemplateInfo[] + templates: Ref | TemplateInfo[], + currentScope?: Ref ) { const settingStore = useSettingStore() const rankingStore = useTemplateRankingStore() @@ -84,7 +85,41 @@ export function useTemplateFiltering( return ['ComfyUI', 'External or Remote API'] }) - const debouncedSearchQuery = refDebounced(searchQuery, 50) + // Compute which selected filters are actually applicable to the current scope + const activeModels = computed(() => { + if (!currentScope) { + return selectedModels.value + } + return selectedModels.value.filter((model) => + availableModels.value.includes(model) + ) + }) + + const activeUseCases = computed(() => { + if (!currentScope) { + return selectedUseCases.value + } + return selectedUseCases.value.filter((useCase) => + availableUseCases.value.includes(useCase) + ) + }) + + // Track which filters are inactive (selected but not applicable) + const inactiveModels = computed(() => { + if (!currentScope) return [] + return selectedModels.value.filter( + (model) => !availableModels.value.includes(model) + ) + }) + + const inactiveUseCases = computed(() => { + if (!currentScope) return [] + return selectedUseCases.value.filter( + (useCase) => !availableUseCases.value.includes(useCase) + ) + }) + + const debouncedSearchQuery = refThrottled(searchQuery, 50) const filteredBySearch = computed(() => { if (!debouncedSearchQuery.value.trim()) { @@ -96,7 +131,8 @@ export function useTemplateFiltering( }) const filteredByModels = computed(() => { - if (selectedModels.value.length === 0) { + // Use active models instead of selected models for filtering + if (activeModels.value.length === 0) { return filteredBySearch.value } @@ -104,14 +140,15 @@ export function useTemplateFiltering( if (!template.models || !Array.isArray(template.models)) { return false } - return selectedModels.value.some((selectedModel) => - template.models?.includes(selectedModel) + return activeModels.value.some((activeModel) => + template.models?.includes(activeModel) ) }) }) const filteredByUseCases = computed(() => { - if (selectedUseCases.value.length === 0) { + // Use active use cases instead of selected use cases for filtering + if (activeUseCases.value.length === 0) { return filteredByModels.value } @@ -119,13 +156,14 @@ export function useTemplateFiltering( if (!template.tags || !Array.isArray(template.tags)) { return false } - return selectedUseCases.value.some((selectedTag) => - template.tags?.includes(selectedTag) + return activeUseCases.value.some((activeUseCase) => + template.tags?.includes(activeUseCase) ) }) }) const filteredByRunsOn = computed(() => { + // RunsOn filters are scope-independent if (selectedRunsOn.value.length === 0) { return filteredByUseCases.value } @@ -137,10 +175,10 @@ export function useTemplateFiltering( const isExternalAPI = template.openSource === false const isComfyUI = template.openSource !== false - return selectedRunsOn.value.some((selectedRunsOn) => { - if (selectedRunsOn === 'External or Remote API') { + return selectedRunsOn.value.some((runsOn) => { + if (runsOn === 'External or Remote API') { return isExternalAPI - } else if (selectedRunsOn === 'ComfyUI') { + } else if (runsOn === 'ComfyUI') { return isComfyUI } return false @@ -343,6 +381,14 @@ export function useTemplateFiltering( selectedRunsOn, sortBy, + // Computed - Active filters (actually applied) + activeModels, + activeUseCases, + + // Computed - Inactive filters (selected but not applicable) + inactiveModels, + inactiveUseCases, + // Computed filteredTemplates, availableModels, diff --git a/src/core/graph/widgets/dynamicWidgets.test.ts b/src/core/graph/widgets/dynamicWidgets.test.ts index c2c4743f7..0252691d1 100644 --- a/src/core/graph/widgets/dynamicWidgets.test.ts +++ b/src/core/graph/widgets/dynamicWidgets.test.ts @@ -175,4 +175,32 @@ describe('Autogrow', () => { await nextTick() expect(node.inputs.length).toBe(5) }) + test('Can deserialize a complex node', async () => { + const graph = new LGraph() + const node = testNode() + graph.add(node) + addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'a' }) + addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'b' }) + addNodeInput(node, { name: 'aa', isOptional: false, type: 'IMAGE' }) + + connectInput(node, 0, graph) + connectInput(node, 1, graph) + connectInput(node, 3, graph) + connectInput(node, 4, graph) + + const serialized = graph.serialize() + graph.clear() + graph.configure(serialized) + const newNode = graph.nodes[0]! + + expect(newNode.inputs.map((i) => i.name)).toStrictEqual([ + '0.a0', + '0.a1', + '0.a2', + '1.b0', + '1.b1', + '1.b2', + 'aa' + ]) + }) }) diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index 406822d43..7e3f7fd71 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -1,4 +1,5 @@ import { remove } from 'es-toolkit' +import { shallowReactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import type { @@ -342,7 +343,9 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) { //ensure outputs get updated const index = node.inputs.length - 1 requestAnimationFrame(() => { - const input = node.inputs.at(index)! + const input = node.inputs[index] + if (!input) return + node.inputs[index] = shallowReactive(input) node.onConnectionsChange?.( LiteGraph.INPUT, index, @@ -385,20 +388,32 @@ function addAutogrowGroup( ...autogrowOrdinalToName(ordinal, input.name, groupName, node) })) - const newInputs = namedSpecs - .filter( - (namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name) - ) - .map((namedSpec) => { - addNodeInput(node, namedSpec) - const input = spliceInputs(node, node.inputs.length - 1, 1)[0] - if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget)) - ensureWidgetForInput(node, input) - return input - }) + const newInputs = namedSpecs.map((namedSpec) => { + addNodeInput(node, namedSpec) + const input = spliceInputs(node, node.inputs.length - 1, 1)[0] + if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget)) + ensureWidgetForInput(node, input) + return input + }) + for (const newInput of newInputs) { + for (const existingInput of remove( + node.inputs, + (inp) => inp.name === newInput.name + )) { + //NOTE: link.target_slot is updated on spliceInputs call + newInput.link ??= existingInput.link + } + } + + const targetName = autogrowOrdinalToName( + ordinal - 1, + inputSpecs.at(-1)!.name, + groupName, + node + ).name const lastIndex = node.inputs.findLastIndex((inp) => - inp.name.startsWith(groupName) + inp.name.startsWith(targetName) ) const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1 spliceInputs(node, insertionIndex, 0, ...newInputs) @@ -427,13 +442,14 @@ function autogrowInputConnected(index: number, node: AutogrowNode) { const input = node.inputs[index] const groupName = input.name.slice(0, input.name.lastIndexOf('.')) const lastInput = node.inputs.findLast((inp) => - inp.name.startsWith(groupName) + inp.name.startsWith(groupName + '.') ) const ordinal = resolveAutogrowOrdinal(input.name, groupName, node) if ( !lastInput || ordinal == undefined || - ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node) + (ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node) && + !app.configuringGraph) ) return addAutogrowGroup(ordinal + 1, groupName, node) @@ -453,6 +469,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) { inp.name.lastIndexOf('.') === groupName.length ) const stride = inputSpecs.length + if (stride + index >= node.inputs.length) return if (groupInputs.length % stride !== 0) { console.error('Failed to group multi-input autogrow inputs') return @@ -473,10 +490,24 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) { const curIndex = node.inputs.findIndex((inp) => inp === curInput) if (curIndex === -1) throw new Error('missing input') link.target_slot = curIndex + node.onConnectionsChange?.( + LiteGraph.INPUT, + curIndex, + true, + link, + curInput + ) } const lastInput = groupInputs.at(column - stride) if (!lastInput) continue lastInput.link = null + node.onConnectionsChange?.( + LiteGraph.INPUT, + node.inputs.length + column - stride, + false, + null, + lastInput + ) } const removalChecks = groupInputs.slice((min - 1) * stride) let i @@ -564,5 +595,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) { prefix, inputSpecs: inputsV2 } - for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node) + for (let i = 0; i === 0 || i < min; i++) + addAutogrowGroup(i, inputSpecV2.name, node) } diff --git a/src/extensions/core/README.md b/src/extensions/core/README.md index aca0a935d..2855f9345 100644 --- a/src/extensions/core/README.md +++ b/src/extensions/core/README.md @@ -25,4 +25,4 @@ and more. - [Extension Development Guide](/docs/extensions/development.md) - How to develop extensions - [Extension Documentation Index](/docs/extensions/README.md) - Overview of all extension docs -- [ComfyExtension Interface](../../types/comfy.ts) - TypeScript interface for extensions \ No newline at end of file +- [ComfyExtension Interface](../../types/comfy.ts) - TypeScript interface for extensions diff --git a/src/extensions/core/clipspace.ts b/src/extensions/core/clipspace.ts index bc644d8d7..2fe7378de 100644 --- a/src/extensions/core/clipspace.ts +++ b/src/extensions/core/clipspace.ts @@ -47,8 +47,9 @@ export class ClipspaceDialog extends ComfyDialog { if (ClipspaceDialog.instance) { const self = ClipspaceDialog.instance // allow reconstruct controls when copying from non-image to image content. + const imgSettings = self.createImgSettings() const children = $el('div.comfy-modal-content', [ - self.createImgSettings(), + ...(imgSettings ? [imgSettings] : []), ...self.createButtons() ]) @@ -103,7 +104,7 @@ export class ClipspaceDialog extends ComfyDialog { return buttons } - createImgSettings() { + createImgSettings(): HTMLTableElement | null { if (ComfyApp.clipspace?.imgs) { const combo_items = [] const imgs = ComfyApp.clipspace.imgs @@ -167,14 +168,14 @@ export class ClipspaceDialog extends ComfyDialog { return $el('table', {}, [row1, row2, row3]) } else { - return [] + return null } } - createImgPreview() { + createImgPreview(): HTMLImageElement | null { if (ComfyApp.clipspace?.imgs) { return $el('img', { id: 'clipspace_preview', ondragstart: () => false }) - } else return [] + } else return null } override show() { diff --git a/src/extensions/core/cloudFeedbackTopbarButton.ts b/src/extensions/core/cloudFeedbackTopbarButton.ts index 144506d7e..b19f057a7 100644 --- a/src/extensions/core/cloudFeedbackTopbarButton.ts +++ b/src/extensions/core/cloudFeedbackTopbarButton.ts @@ -1,10 +1,17 @@ import { t } from '@/i18n' +import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config' import { useExtensionService } from '@/services/extensionService' import type { ActionBarButton } from '@/types/comfy' -// Zendesk feedback URL - update this with the actual URL -const ZENDESK_FEEDBACK_URL = - 'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236' +const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new' +const ZENDESK_FEEDBACK_FORM_ID = '43066738713236' + +const distribution = getDistribution() +const params = new URLSearchParams({ + ticket_form_id: ZENDESK_FEEDBACK_FORM_ID, + [ZENDESK_FIELDS.DISTRIBUTION]: distribution +}) +const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}` const buttons: ActionBarButton[] = [ { @@ -12,7 +19,7 @@ const buttons: ActionBarButton[] = [ label: t('actionbar.feedback'), tooltip: t('actionbar.feedbackTooltip'), onClick: () => { - window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer') + window.open(feedbackUrl, '_blank', 'noopener,noreferrer') } } ] diff --git a/src/extensions/core/cloudRemoteConfig.ts b/src/extensions/core/cloudRemoteConfig.ts index 0628800d8..d3c1dc7ac 100644 --- a/src/extensions/core/cloudRemoteConfig.ts +++ b/src/extensions/core/cloudRemoteConfig.ts @@ -2,7 +2,6 @@ import { watchDebounced } from '@vueuse/core' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' -import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig' import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig' import { useExtensionService } from '@/services/extensionService' @@ -17,16 +16,18 @@ useExtensionService().registerExtension({ const { isLoggedIn } = useCurrentUser() const { isActiveSubscription } = useSubscription() + // Refresh config when subscription status changes + // Initial auth-aware refresh happens in WorkspaceAuthGate before app renders watchDebounced( [isLoggedIn, isActiveSubscription], () => { if (!isLoggedIn.value) return void refreshRemoteConfig() }, - { debounce: 256, immediate: true } + { debounce: 256 } ) - // Poll for config updates every 10 minutes - setInterval(() => void loadRemoteConfig(), 600_000) + // Poll for config updates every 10 minutes (with auth) + setInterval(() => void refreshRemoteConfig(), 600_000) } }) diff --git a/src/extensions/core/contextMenuFilter.name.test.ts b/src/extensions/core/contextMenuFilter.name.test.ts index 1fb0b5fd5..2ee4f6a21 100644 --- a/src/extensions/core/contextMenuFilter.name.test.ts +++ b/src/extensions/core/contextMenuFilter.name.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' +import type { + IContextMenuValue, + LGraphNode +} from '@/lib/litegraph/src/litegraph' import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' /** @@ -18,11 +22,12 @@ describe('Context Menu Extension Name in Warnings', () => { // Extension monkey-patches the method const original = LGraphCanvas.prototype.getCanvasMenuOptions - LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { - const items = (original as any).apply(this, args) - items.push({ content: 'My Custom Menu Item', callback: () => {} }) - return items - } + LGraphCanvas.prototype.getCanvasMenuOptions = + function (): (IContextMenuValue | null)[] { + const items = original.call(this) + items.push({ content: 'My Custom Menu Item', callback: () => {} }) + return items + } // Clear extension (happens after setup completes) legacyMenuCompat.setCurrentExtension(null) @@ -49,8 +54,8 @@ describe('Context Menu Extension Name in Warnings', () => { // Extension monkey-patches the method const original = LGraphCanvas.prototype.getNodeMenuOptions - LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) { - const items = (original as any).apply(this, args) + LGraphCanvas.prototype.getNodeMenuOptions = function (node: LGraphNode) { + const items = original.call(this, node) items.push({ content: 'My Node Menu Item', callback: () => {} }) return items } diff --git a/src/extensions/core/contextMenuFilter.test.ts b/src/extensions/core/contextMenuFilter.test.ts index 5cd9d3664..72a2d7604 100644 --- a/src/extensions/core/contextMenuFilter.test.ts +++ b/src/extensions/core/contextMenuFilter.test.ts @@ -7,6 +7,10 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useExtensionService } from '@/services/extensionService' import { useExtensionStore } from '@/stores/extensionStore' import type { ComfyExtension } from '@/types/comfy' +import { + createMockCanvas, + createMockLGraphNode +} from '@/utils/__tests__/litegraphTestUtils' describe('Context Menu Extension API', () => { let mockCanvas: LGraphCanvas @@ -35,7 +39,7 @@ describe('Context Menu Extension API', () => { // Mock extensions const createCanvasMenuExtension = ( name: string, - items: IContextMenuValue[] + items: (IContextMenuValue | null)[] ): ComfyExtension => ({ name, getCanvasMenuItems: () => items @@ -54,16 +58,16 @@ describe('Context Menu Extension API', () => { extensionStore = useExtensionStore() extensionService = useExtensionService() - mockCanvas = { + mockCanvas = createMockCanvas({ graph_mouse: [100, 100], selectedItems: new Set() - } as unknown as LGraphCanvas + }) - mockNode = { + mockNode = createMockLGraphNode({ id: 1, type: 'TestNode', pos: [0, 0] - } as unknown as LGraphNode + }) }) describe('collectCanvasMenuItems', () => { @@ -79,7 +83,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() + .flat() as IContextMenuValue[] expect(items).toHaveLength(3) expect(items[0]).toMatchObject({ content: 'Canvas Item 1' }) @@ -99,7 +103,7 @@ describe('Context Menu Extension API', () => { ] } }, - null as unknown as IContextMenuValue, + null, { content: 'After Separator', callback: () => {} } ]) @@ -107,7 +111,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() + .flat() as IContextMenuValue[] expect(items).toHaveLength(3) expect(items[0].content).toBe('Menu with Submenu') @@ -129,7 +133,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() + .flat() as IContextMenuValue[] expect(items).toHaveLength(1) expect(items[0].content).toBe('Canvas Item 1') @@ -146,11 +150,11 @@ describe('Context Menu Extension API', () => { // Collect items multiple times (simulating repeated menu opens) const items1: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() + .flat() as IContextMenuValue[] const items2: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() + .flat() as IContextMenuValue[] // Both collections should have the same items (no duplication) expect(items1).toHaveLength(2) @@ -180,7 +184,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() + .flat() as IContextMenuValue[] expect(items).toHaveLength(3) expect(items[0]).toMatchObject({ content: 'Node Item 1' }) @@ -205,7 +209,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() + .flat() as IContextMenuValue[] expect(items[0].content).toBe('Node Menu with Submenu') expect(items[0].submenu?.options).toHaveLength(2) @@ -222,7 +226,7 @@ describe('Context Menu Extension API', () => { const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() + .flat() as IContextMenuValue[] expect(items).toHaveLength(1) expect(items[0].content).toBe('Node Item 1') diff --git a/src/extensions/core/customCombo.ts b/src/extensions/core/customCombo.ts deleted file mode 100644 index ed7356a40..000000000 --- a/src/extensions/core/customCombo.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { shallowReactive } from 'vue' - -import { useChainCallback } from '@/composables/functional/useChainCallback' -import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' -import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' -import { LLink } from '@/lib/litegraph/src/litegraph' -import { app } from '@/scripts/app' - -function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) { - if (!this.outputs[0].links?.length || !this.graph) return - - const links = [ - ...this.outputs[0].links.map((l) => this.graph!.links[l]), - ...extraLinks - ] - let v = this.widgets?.[0].value - // For each output link copy our value over the original widget value - for (const linkInfo of links) { - const node = this.graph?.getNodeById(linkInfo.target_id) - const input = node?.inputs[linkInfo.target_slot] - if (!input) { - console.warn('Unable to resolve node or input for link', linkInfo) - continue - } - - const widgetName = input.widget?.name - if (!widgetName) { - console.warn('Invalid widget or widget name', input.widget) - continue - } - - const widget = node.widgets?.find((w) => w.name === widgetName) - if (!widget) { - console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`) - continue - } - - widget.value = v - widget.callback?.( - widget.value, - app.canvas, - node, - app.canvas.graph_mouse, - {} as CanvasPointerEvent - ) - } -} - -function onNodeCreated(this: LGraphNode) { - this.applyToGraph = useChainCallback(this.applyToGraph, applyToGraph) - - const comboWidget = this.widgets![0] - const values = shallowReactive([]) - comboWidget.options.values = values - - const updateCombo = () => { - values.splice( - 0, - values.length, - ...this.widgets!.filter( - (w) => w.name.startsWith('option') && w.value - ).map((w) => `${w.value}`) - ) - if (app.configuringGraph) return - if (values.includes(`${comboWidget.value}`)) return - comboWidget.value = values[0] ?? '' - comboWidget.callback?.(comboWidget.value) - } - comboWidget.callback = useChainCallback(comboWidget.callback, () => - this.applyToGraph!() - ) - - function addOption(node: LGraphNode) { - if (!node.widgets) return - const newCount = node.widgets.length - 1 - node.addWidget('string', `option${newCount}`, '', () => {}) - const widget = node.widgets.at(-1) - if (!widget) return - - let value = '' - Object.defineProperty(widget, 'value', { - get() { - return value - }, - set(v) { - value = v - updateCombo() - if (!node.widgets) return - const lastWidget = node.widgets.at(-1) - if (lastWidget === this) { - if (v) addOption(node) - return - } - if (v || node.widgets.at(-2) !== this || lastWidget?.value) return - node.widgets.pop() - node.computeSize(node.size) - this.callback(v) - } - }) - } - addOption(this) -} - -app.registerExtension({ - name: 'Comfy.CustomCombo', - beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== 'CustomCombo') return - nodeType.prototype.onNodeCreated = useChainCallback( - nodeType.prototype.onNodeCreated, - onNodeCreated - ) - } -}) diff --git a/src/extensions/core/customWidgets.ts b/src/extensions/core/customWidgets.ts new file mode 100644 index 000000000..df796c03e --- /dev/null +++ b/src/extensions/core/customWidgets.ts @@ -0,0 +1,210 @@ +import { shallowReactive } from 'vue' + +import { useChainCallback } from '@/composables/functional/useChainCallback' +import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' +import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { LLink } from '@/lib/litegraph/src/litegraph' +import { app } from '@/scripts/app' + +function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) { + if (!this.outputs[0].links?.length || !this.graph) return + + const links = [ + ...this.outputs[0].links.map((l) => this.graph!.links[l]), + ...extraLinks + ] + let v = this.widgets?.[0].value + // For each output link copy our value over the original widget value + for (const linkInfo of links) { + const node = this.graph?.getNodeById(linkInfo.target_id) + const input = node?.inputs[linkInfo.target_slot] + if (!input) { + console.warn('Unable to resolve node or input for link', linkInfo) + continue + } + + const widgetName = input.widget?.name + if (!widgetName) { + console.warn('Invalid widget or widget name', input.widget) + continue + } + + const widget = node.widgets?.find((w) => w.name === widgetName) + if (!widget) { + console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`) + continue + } + + widget.value = v + widget.callback?.( + widget.value, + app.canvas, + node, + app.canvas.graph_mouse, + {} as CanvasPointerEvent + ) + } +} + +function onCustomComboCreated(this: LGraphNode) { + this.applyToGraph = applyToGraph + + const comboWidget = this.widgets![0] + const values = shallowReactive([]) + comboWidget.options.values = values + + const updateCombo = () => { + values.splice( + 0, + values.length, + ...this.widgets!.filter( + (w) => w.name.startsWith('option') && w.value + ).map((w) => `${w.value}`) + ) + if (app.configuringGraph) return + if (values.includes(`${comboWidget.value}`)) return + comboWidget.value = values[0] ?? '' + comboWidget.callback?.(comboWidget.value) + } + comboWidget.callback = useChainCallback(comboWidget.callback, () => + this.applyToGraph!() + ) + + function addOption(node: LGraphNode) { + if (!node.widgets) return + const newCount = node.widgets.length - 1 + node.addWidget('string', `option${newCount}`, '', () => {}) + const widget = node.widgets.at(-1) + if (!widget) return + + let value = '' + Object.defineProperty(widget, 'value', { + get() { + return value + }, + set(v) { + value = v + updateCombo() + if (!node.widgets) return + const lastWidget = node.widgets.at(-1) + if (lastWidget === this) { + if (v) addOption(node) + return + } + if (v || node.widgets.at(-2) !== this || lastWidget?.value) return + node.widgets.pop() + node.computeSize(node.size) + this.callback(v) + } + }) + } + const widgets = this.widgets! + widgets.push({ + name: 'index', + type: 'hidden', + get value() { + return widgets.slice(2).findIndex((w) => w.value === comboWidget.value) + }, + set value(_) {}, + draw: () => undefined, + computeSize: () => [0, -4], + options: { hidden: true }, + y: 0 + }) + addOption(this) +} + +function onCustomIntCreated(this: LGraphNode) { + const valueWidget = this.widgets?.[0] + if (!valueWidget) return + + Object.defineProperty(valueWidget.options, 'min', { + get: () => this.properties.min ?? -(2 ** 63), + set: (v) => { + this.properties.min = v + valueWidget.callback?.(valueWidget.value) + } + }) + Object.defineProperty(valueWidget.options, 'max', { + get: () => this.properties.max ?? 2 ** 63, + set: (v) => { + this.properties.max = v + valueWidget.callback?.(valueWidget.value) + } + }) + Object.defineProperty(valueWidget.options, 'step2', { + get: () => this.properties.step ?? 1, + set: (v) => { + this.properties.step = v + valueWidget.callback?.(valueWidget.value) // for vue reactivity + } + }) +} +function onCustomFloatCreated(this: LGraphNode) { + const valueWidget = this.widgets?.[0] + if (!valueWidget) return + + Object.defineProperty(valueWidget.options, 'min', { + get: () => this.properties.min ?? -Infinity, + set: (v) => { + this.properties.min = v + valueWidget.callback?.(valueWidget.value) + } + }) + Object.defineProperty(valueWidget.options, 'max', { + get: () => this.properties.max ?? Infinity, + set: (v) => { + this.properties.max = v + valueWidget.callback?.(valueWidget.value) + } + }) + Object.defineProperty(valueWidget.options, 'precision', { + get: () => this.properties.precision ?? 1, + set: (v) => { + this.properties.precision = v + valueWidget.callback?.(valueWidget.value) + } + }) + Object.defineProperty(valueWidget.options, 'step2', { + get: () => { + if (this.properties.step) return this.properties.step + + const { precision } = this.properties + return typeof precision === 'number' ? 5 * 10 ** -precision : 1 + }, + set: (v) => (this.properties.step = v) + }) + Object.defineProperty(valueWidget.options, 'round', { + get: () => { + if (this.properties.round) return this.properties.round + + const { precision } = this.properties + return typeof precision === 'number' ? 10 ** -precision : 0.1 + }, + set: (v) => { + this.properties.round = v + valueWidget.callback?.(valueWidget.value) + } + }) +} + +app.registerExtension({ + name: 'Comfy.CustomWidgets', + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name === 'CustomCombo') + nodeType.prototype.onNodeCreated = useChainCallback( + nodeType.prototype.onNodeCreated, + onCustomComboCreated + ) + else if (nodeData?.name === 'PrimitiveInt') + nodeType.prototype.onNodeCreated = useChainCallback( + nodeType.prototype.onNodeCreated, + onCustomIntCreated + ) + else if (nodeData?.name === 'PrimitiveFloat') + nodeType.prototype.onNodeCreated = useChainCallback( + nodeType.prototype.onNodeCreated, + onCustomFloatCreated + ) + } +}) diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index a7af7361a..db543c30a 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -368,7 +368,7 @@ export class GroupNodeConfig { } getNodeDef( - node: GroupNodeData + node: GroupNodeData | GroupNodeWorkflowData['nodes'][number] ): GroupNodeDef | ComfyNodeDef | null | undefined { if (node.type) { const def = globalDefs[node.type] @@ -386,7 +386,8 @@ export class GroupNodeConfig { let type: string | number | null = linksFrom[0]?.[0]?.[5] ?? null if (type === 'COMBO') { // Use the array items - const source = node.outputs?.[0]?.widget?.name + const output = node.outputs?.[0] as GroupNodeOutput | undefined + const source = output?.widget?.name const nodeIdx = linksFrom[0]?.[0]?.[2] if (source && nodeIdx != null) { const fromTypeName = this.nodeData.nodes[Number(nodeIdx)]?.type diff --git a/src/extensions/core/groupNodeManage.css b/src/extensions/core/groupNodeManage.css index 932167ab3..788ea807d 100644 --- a/src/extensions/core/groupNodeManage.css +++ b/src/extensions/core/groupNodeManage.css @@ -95,7 +95,7 @@ font-size: 15px; } .comfy-group-manage-node header a:last-child { - border-right: none; + border-right: none; } .comfy-group-manage-node header a:not(.active):hover { text-decoration: underline; @@ -123,7 +123,7 @@ background: var(--comfy-input-bg); padding: 5px 10px; } -.comfy-group-manage-node-page input[type="text"] { +.comfy-group-manage-node-page input[type='text'] { flex: auto; } .comfy-group-manage-node-page label { diff --git a/src/extensions/core/groupNodeManage.ts b/src/extensions/core/groupNodeManage.ts index 0e91af317..38bddb129 100644 --- a/src/extensions/core/groupNodeManage.ts +++ b/src/extensions/core/groupNodeManage.ts @@ -1,9 +1,11 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' -import { - type LGraphNode, - type LGraphNodeConstructor, - LiteGraph +import type { + GroupNodeConfigEntry, + GroupNodeWorkflowData, + LGraphNode, + LGraphNodeConstructor } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useToastStore } from '@/platform/updates/common/toastStore' import { type ComfyApp, app } from '../../scripts/app' @@ -15,18 +17,20 @@ import './groupNodeManage.css' const ORDER: symbol = Symbol() -// @ts-expect-error fixme ts strict error -function merge(target, source) { - if (typeof target === 'object' && typeof source === 'object') { - for (const key in source) { - const sv = source[key] - if (typeof sv === 'object') { - let tv = target[key] - if (!tv) tv = target[key] = {} - merge(tv, source[key]) - } else { - target[key] = sv +function merge( + target: Record, + source: Record +): Record { + for (const key in source) { + const sv = source[key] + if (typeof sv === 'object' && sv !== null) { + let tv = target[key] as Record | undefined + if (!tv) { + tv = target[key] = {} } + merge(tv, sv as Record) + } else { + target[key] = sv } } @@ -34,8 +38,7 @@ function merge(target, source) { } export class ManageGroupDialog extends ComfyDialog { - // @ts-expect-error fixme ts strict error - tabs: Record< + tabs!: Record< 'Inputs' | 'Outputs' | 'Widgets', { tab: HTMLAnchorElement; page: HTMLElement } > @@ -52,31 +55,26 @@ export class ManageGroupDialog extends ComfyDialog { > > > = {} - // @ts-expect-error fixme ts strict error - nodeItems: any[] + nodeItems!: HTMLLIElement[] app: ComfyApp - // @ts-expect-error fixme ts strict error - groupNodeType: LGraphNodeConstructor - groupNodeDef: any - groupData: any + groupNodeType!: LGraphNodeConstructor + groupData!: GroupNodeConfig - // @ts-expect-error fixme ts strict error - innerNodesList: HTMLUListElement - // @ts-expect-error fixme ts strict error - widgetsPage: HTMLElement - // @ts-expect-error fixme ts strict error - inputsPage: HTMLElement - // @ts-expect-error fixme ts strict error - outputsPage: HTMLElement - draggable: any + innerNodesList!: HTMLUListElement + widgetsPage!: HTMLElement + inputsPage!: HTMLElement + outputsPage!: HTMLElement + draggable: DraggableList | undefined - get selectedNodeInnerIndex() { - // @ts-expect-error fixme ts strict error - return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex + get selectedNodeInnerIndex(): number { + const index = this.selectedNodeIndex + if (index == null) throw new Error('No node selected') + const item = this.nodeItems[index] + if (!item?.dataset.nodeindex) throw new Error('Invalid node item') + return +item.dataset.nodeindex } - // @ts-expect-error fixme ts strict error - constructor(app) { + constructor(app: ComfyApp) { super() this.app = app this.element = $el('dialog.comfy-group-manage', { @@ -84,19 +82,15 @@ export class ManageGroupDialog extends ComfyDialog { }) as HTMLDialogElement } - // @ts-expect-error fixme ts strict error - changeTab(tab) { + changeTab(tab: keyof ManageGroupDialog['tabs']): void { this.tabs[this.selectedTab].tab.classList.remove('active') this.tabs[this.selectedTab].page.classList.remove('active') - // @ts-expect-error fixme ts strict error this.tabs[tab].tab.classList.add('active') - // @ts-expect-error fixme ts strict error this.tabs[tab].page.classList.add('active') this.selectedTab = tab } - // @ts-expect-error fixme ts strict error - changeNode(index, force?) { + changeNode(index: number, force?: boolean): void { if (!force && this.selectedNodeIndex === index) return if (this.selectedNodeIndex != null) { @@ -122,43 +116,41 @@ export class ManageGroupDialog extends ComfyDialog { this.groupNodeType = LiteGraph.registered_node_types[ `${PREFIX}${SEPARATOR}` + this.selectedGroup ] as unknown as LGraphNodeConstructor - this.groupNodeDef = this.groupNodeType.nodeData - this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType) + this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)! } - // @ts-expect-error fixme ts strict error - changeGroup(group, reset = true) { + changeGroup(group: string, reset = true): void { this.selectedGroup = group this.getGroupData() const nodes = this.groupData.nodeData.nodes - // @ts-expect-error fixme ts strict error - this.nodeItems = nodes.map((n, i) => - $el( - 'li.draggable-item', - { - dataset: { - nodeindex: n.index + '' - }, - onclick: () => { - this.changeNode(i) - } - }, - [ - $el('span.drag-handle'), - $el( - 'div', - { - textContent: n.title ?? n.type + this.nodeItems = nodes.map( + (n, i) => + $el( + 'li.draggable-item', + { + dataset: { + nodeindex: n.index + '' }, - n.title - ? $el('span', { - textContent: n.type - }) - : [] - ) - ] - ) + onclick: () => { + this.changeNode(i) + } + }, + [ + $el('span.drag-handle'), + $el( + 'div', + { + textContent: n.title ?? n.type + }, + n.title + ? $el('span', { + textContent: n.type + }) + : [] + ) + ] + ) as HTMLLIElement ) this.innerNodesList.replaceChildren(...this.nodeItems) @@ -167,47 +159,46 @@ export class ManageGroupDialog extends ComfyDialog { this.selectedNodeIndex = null this.changeNode(0) } else { - const items = this.draggable.getAllItems() - // @ts-expect-error fixme ts strict error - let index = items.findIndex((item) => item.classList.contains('selected')) - if (index === -1) index = this.selectedNodeIndex + const items = this.draggable!.getAllItems() + let index = items.findIndex((item: Element) => + item.classList.contains('selected') + ) + if (index === -1) index = this.selectedNodeIndex! this.changeNode(index, true) } const ordered = [...nodes] this.draggable?.dispose() this.draggable = new DraggableList(this.innerNodesList, 'li') - this.draggable.addEventListener( - 'dragend', - // @ts-expect-error fixme ts strict error - ({ detail: { oldPosition, newPosition } }) => { - if (oldPosition === newPosition) return - ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]) - for (let i = 0; i < ordered.length; i++) { - this.storeModification({ - nodeIndex: ordered[i].index, - section: ORDER, - prop: 'order', - value: i - }) - } + this.draggable.addEventListener('dragend', (e: Event) => { + const { oldPosition, newPosition } = (e as CustomEvent).detail + if (oldPosition === newPosition) return + ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]) + for (let i = 0; i < ordered.length; i++) { + this.storeModification({ + nodeIndex: ordered[i].index, + section: ORDER, + prop: 'order', + value: i + }) } - ) + }) } storeModification(props: { nodeIndex?: number - section: symbol + section: string | symbol prop: string - value: any + value: unknown }) { const { nodeIndex, section, prop, value } = props - // @ts-expect-error fixme ts strict error - const groupMod = (this.modifications[this.selectedGroup] ??= {}) - const nodesMod = (groupMod.nodes ??= {}) + const groupKey = this.selectedGroup! + const groupMod = (this.modifications[groupKey] ??= {}) + const nodesMod = ((groupMod as Record).nodes ??= + {}) as Record>> const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}) const typeMod = (nodeMod[section] ??= {}) - if (typeof value === 'object') { + if (typeof value === 'object' && value !== null) { const objMod = (typeMod[prop] ??= {}) Object.assign(objMod, value) } else { @@ -215,35 +206,45 @@ export class ManageGroupDialog extends ComfyDialog { } } - // @ts-expect-error fixme ts strict error - getEditElement(section, prop, value, placeholder, checked, checkable = true) { - if (value === placeholder) value = '' + getEditElement( + section: string, + prop: string | number, + value: unknown, + placeholder: string, + checked: boolean, + checkable = true + ): HTMLDivElement { + let displayValue = value === placeholder ? '' : value - const mods = - // @ts-expect-error fixme ts strict error - this.modifications[this.selectedGroup]?.nodes?.[ - this.selectedNodeInnerIndex - ]?.[section]?.[prop] - if (mods) { - if (mods.name != null) { - value = mods.name + const groupKey = this.selectedGroup! + const mods = ( + this.modifications[groupKey] as Record | undefined + )?.nodes as + | Record< + number, + Record> + > + | undefined + const modEntry = mods?.[this.selectedNodeInnerIndex]?.[section]?.[prop] + if (modEntry) { + if (modEntry.name != null) { + displayValue = modEntry.name } - if (mods.visible != null) { - checked = mods.visible + if (modEntry.visible != null) { + checked = modEntry.visible } } return $el('div', [ $el('input', { - value, + value: displayValue as string, placeholder, type: 'text', - // @ts-expect-error fixme ts strict error - onchange: (e) => { + onchange: (e: Event) => { this.storeModification({ section, - prop, - value: { name: e.target.value } + prop: String(prop), + value: { name: (e.target as HTMLInputElement).value } }) } }), @@ -252,25 +253,23 @@ export class ManageGroupDialog extends ComfyDialog { type: 'checkbox', checked, disabled: !checkable, - // @ts-expect-error fixme ts strict error - onchange: (e) => { + onchange: (e: Event) => { this.storeModification({ section, - prop, - value: { visible: !!e.target.checked } + prop: String(prop), + value: { visible: !!(e.target as HTMLInputElement).checked } }) } }) ]) - ]) + ]) as HTMLDivElement } buildWidgetsPage() { const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex] const items = Object.keys(widgets ?? {}) - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] + const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]! const config = type.config?.[this.selectedNodeInnerIndex]?.input this.widgetsPage.replaceChildren( ...items.map((oldName) => { @@ -289,28 +288,25 @@ export class ManageGroupDialog extends ComfyDialog { buildInputsPage() { const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex] const items = Object.keys(inputs ?? {}) - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] + const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]! const config = type.config?.[this.selectedNodeInnerIndex]?.input - this.inputsPage.replaceChildren( - // @ts-expect-error fixme ts strict error - ...items - .map((oldName) => { - let value = inputs[oldName] - if (!value) { - return - } + const elements = items + .map((oldName) => { + const value = inputs[oldName] + if (!value) { + return null + } - return this.getEditElement( - 'input', - oldName, - value, - oldName, - config?.[oldName]?.visible !== false - ) - }) - .filter(Boolean) - ) + return this.getEditElement( + 'input', + oldName, + value, + oldName, + config?.[oldName]?.visible !== false + ) + }) + .filter((el): el is HTMLDivElement => el !== null) + this.inputsPage.replaceChildren(...elements) return !!items.length } @@ -323,38 +319,35 @@ export class ManageGroupDialog extends ComfyDialog { const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex] - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] + const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]! const config = type.config?.[this.selectedNodeInnerIndex]?.output const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex] const checkable = node.type !== 'PrimitiveNode' - this.outputsPage.replaceChildren( - ...outputs - // @ts-expect-error fixme ts strict error - .map((type, slot) => { - const groupOutputIndex = groupOutputs?.[slot] - const oldName = innerNodeDef.output_name?.[slot] ?? type - let value = config?.[slot]?.name - const visible = config?.[slot]?.visible || groupOutputIndex != null - if (!value || value === oldName) { - value = '' - } - return this.getEditElement( - 'output', - slot, - value, - oldName, - visible, - checkable - ) - }) - .filter(Boolean) - ) + const elements = outputs.map((outputType: unknown, slot: number) => { + const groupOutputIndex = groupOutputs?.[slot] + const oldName = innerNodeDef?.output_name?.[slot] ?? String(outputType) + let value = config?.[slot]?.name + const visible = config?.[slot]?.visible || groupOutputIndex != null + if (!value || value === oldName) { + value = '' + } + return this.getEditElement( + 'output', + slot, + value, + oldName, + visible, + checkable + ) + }) + this.outputsPage.replaceChildren(...elements) return !!outputs.length } - // @ts-expect-error fixme ts strict error - show(type?) { + override show(groupNodeType?: string | HTMLElement | HTMLElement[]): void { + // Extract string type - this method repurposes the show signature + const nodeType = + typeof groupNodeType === 'string' ? groupNodeType : undefined const groupNodes = Object.keys(app.rootGraph.extra?.groupNodes ?? {}).sort( (a, b) => a.localeCompare(b) ) @@ -371,24 +364,27 @@ export class ManageGroupDialog extends ComfyDialog { this.outputsPage ]) - this.tabs = [ + type TabName = 'Inputs' | 'Widgets' | 'Outputs' + const tabEntries: [TabName, HTMLElement][] = [ ['Inputs', this.inputsPage], ['Widgets', this.widgetsPage], ['Outputs', this.outputsPage] - // @ts-expect-error fixme ts strict error - ].reduce((p, [name, page]: [string, HTMLElement]) => { - // @ts-expect-error fixme ts strict error - p[name] = { - tab: $el('a', { - onclick: () => { - this.changeTab(name) - }, - textContent: name - }), - page - } - return p - }, {}) as any + ] + this.tabs = tabEntries.reduce( + (p, [name, page]) => { + p[name] = { + tab: $el('a', { + onclick: () => { + this.changeTab(name) + }, + textContent: name + }) as HTMLAnchorElement, + page + } + return p + }, + {} as ManageGroupDialog['tabs'] + ) const outer = $el('div.comfy-group-manage-outer', [ $el('header', [ @@ -396,15 +392,14 @@ export class ManageGroupDialog extends ComfyDialog { $el( 'select', { - // @ts-expect-error fixme ts strict error - onchange: (e) => { - this.changeGroup(e.target.value) + onchange: (e: Event) => { + this.changeGroup((e.target as HTMLSelectElement).value) } }, groupNodes.map((g) => $el('option', { textContent: g, - selected: `${PREFIX}${SEPARATOR}${g}` === type, + selected: `${PREFIX}${SEPARATOR}${g}` === nodeType, value: g }) ) @@ -439,8 +434,7 @@ export class ManageGroupDialog extends ComfyDialog { `Are you sure you want to remove the node: "${this.selectedGroup}"` ) ) { - // @ts-expect-error fixme ts strict error - delete app.rootGraph.extra.groupNodes[this.selectedGroup] + delete app.rootGraph.extra.groupNodes![this.selectedGroup!] LiteGraph.unregisterNodeType( `${PREFIX}${SEPARATOR}` + this.selectedGroup ) @@ -454,97 +448,106 @@ export class ManageGroupDialog extends ComfyDialog { 'button.comfy-btn', { onclick: async () => { - let nodesByType - let recreateNodes = [] - const types = {} + type NodesByType = Record + let nodesByType: NodesByType | undefined + const recreateNodes: LGraphNode[] = [] + const types: Record = {} for (const g in this.modifications) { - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[g] - let config = (type.config ??= {}) + const groupNodeData = app.rootGraph.extra.groupNodes![g]! + let config = (groupNodeData.config ??= {}) - let nodeMods = this.modifications[g]?.nodes + type NodeMods = Record< + string, + Record> + > + let nodeMods = this.modifications[g]?.nodes as + | NodeMods + | undefined if (nodeMods) { const keys = Object.keys(nodeMods) - // @ts-expect-error fixme ts strict error - if (nodeMods[keys[0]][ORDER]) { + if (nodeMods[keys[0]]?.[ORDER]) { // If any node is reordered, they will all need sequencing - const orderedNodes = [] - const orderedMods = {} - const orderedConfig = {} + const orderedNodes: GroupNodeWorkflowData['nodes'] = [] + const orderedMods: NodeMods = {} + const orderedConfig: Record = + {} for (const n of keys) { - // @ts-expect-error fixme ts strict error - const order = nodeMods[n][ORDER].order - orderedNodes[order] = type.nodes[+n] - // @ts-expect-error fixme ts strict error + const order = (nodeMods[n][ORDER] as { order: number }) + .order + orderedNodes[order] = groupNodeData.nodes[+n] orderedMods[order] = nodeMods[n] orderedNodes[order].index = order } // Rewrite links - for (const l of type.links) { - // @ts-expect-error l[0]/l[2] used as node index - if (l[0] != null) l[0] = type.nodes[l[0]].index - // @ts-expect-error l[0]/l[2] used as node index - if (l[2] != null) l[2] = type.nodes[l[2]].index + const nodesLen = groupNodeData.nodes.length + for (const l of groupNodeData.links) { + const srcIdx = l[0] as number + const dstIdx = l[2] as number + if (srcIdx != null && srcIdx < nodesLen) + l[0] = groupNodeData.nodes[srcIdx].index! + if (dstIdx != null && dstIdx < nodesLen) + l[2] = groupNodeData.nodes[dstIdx].index! } // Rewrite externals - if (type.external) { - for (const ext of type.external) { - if (ext[0] != null) { - // @ts-expect-error ext[0] used as node index - ext[0] = type.nodes[ext[0]].index + if (groupNodeData.external) { + for (const ext of groupNodeData.external) { + const extIdx = ext[0] as number + if (extIdx != null && extIdx < nodesLen) { + ext[0] = groupNodeData.nodes[extIdx].index! } } } // Rewrite modifications for (const id of keys) { - // @ts-expect-error id used as node index - if (config[id]) { - // @ts-expect-error fixme ts strict error - orderedConfig[type.nodes[id].index] = config[id] + if (config[+id]) { + orderedConfig[groupNodeData.nodes[+id].index!] = + config[+id] } - // @ts-expect-error id used as config key - delete config[id] + delete config[+id] } - type.nodes = orderedNodes + groupNodeData.nodes = orderedNodes nodeMods = orderedMods - type.config = config = orderedConfig + groupNodeData.config = config = orderedConfig } - merge(config, nodeMods) + merge( + config as Record, + nodeMods as Record + ) } - // @ts-expect-error fixme ts strict error - types[g] = type + types[g] = groupNodeData if (!nodesByType) { - nodesByType = app.rootGraph.nodes.reduce((p, n) => { - // @ts-expect-error fixme ts strict error - p[n.type] ??= [] - // @ts-expect-error fixme ts strict error - p[n.type].push(n) - return p - }, {}) + nodesByType = app.rootGraph.nodes.reduce( + (p, n) => { + const nodeType = n.type ?? '' + p[nodeType] ??= [] + p[nodeType].push(n) + return p + }, + {} + ) } - // @ts-expect-error fixme ts strict error - const nodes = nodesByType[`${PREFIX}${SEPARATOR}` + g] - if (nodes) recreateNodes.push(...nodes) + const groupTypeNodes = nodesByType[`${PREFIX}${SEPARATOR}` + g] + if (groupTypeNodes) recreateNodes.push(...groupTypeNodes) } await GroupNodeConfig.registerFromWorkflow(types, []) for (const node of recreateNodes) { - node.recreate() + node.recreate?.() } this.modifications = {} this.app.canvas.setDirty(true, true) - this.changeGroup(this.selectedGroup, false) + this.changeGroup(this.selectedGroup!, false) } }, 'Save' @@ -559,8 +562,8 @@ export class ManageGroupDialog extends ComfyDialog { this.element.replaceChildren(outer) this.changeGroup( - type - ? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === type) ?? + nodeType + ? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === nodeType) ?? groupNodes[0]) : groupNodes[0] ) diff --git a/src/extensions/core/imageCrop.ts b/src/extensions/core/imageCrop.ts new file mode 100644 index 000000000..38cf6692a --- /dev/null +++ b/src/extensions/core/imageCrop.ts @@ -0,0 +1,12 @@ +import { useExtensionService } from '@/services/extensionService' + +useExtensionService().registerExtension({ + name: 'Comfy.ImageCrop', + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'ImageCrop') return + + const [oldWidth, oldHeight] = node.size + node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)]) + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index ba36f847a..fe8cbf2c7 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -1,8 +1,8 @@ -import { isCloud } from '@/platform/distribution/types' +import { isCloud, isNightly } from '@/platform/distribution/types' import './clipspace' import './contextMenuFilter' -import './customCombo' +import './customWidgets' import './dynamicPrompts' import './editAttention' import './electronAdapter' @@ -10,6 +10,7 @@ import './groupNode' import './groupNodeManage' import './groupOptions' import './imageCompare' +import './imageCrop' import './load3d' import './maskeditor' import './nodeTemplates' @@ -31,9 +32,18 @@ if (isCloud) { await import('./cloudRemoteConfig') await import('./cloudBadges') await import('./cloudSessionCookie') - await import('./cloudFeedbackTopbarButton') if (window.__CONFIG__?.subscription_required) { await import('./cloudSubscription') } } + +// Feedback button for cloud and nightly builds +if (isCloud || isNightly) { + await import('./cloudFeedbackTopbarButton') +} + +// Nightly-only extensions +if (isNightly && !isCloud) { + await import('./nightlyBadges') +} diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 657a1796b..60690e4f8 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -754,6 +754,60 @@ class Load3d { this.forceRender() } + public async captureThumbnail( + width: number = 256, + height: number = 256 + ): Promise { + if (!this.modelManager.currentModel) { + throw new Error('No model loaded for thumbnail capture') + } + + const savedState = this.cameraManager.getCameraState() + const savedCameraType = this.cameraManager.getCurrentCameraType() + const savedGridVisible = this.sceneManager.gridHelper.visible + + try { + this.sceneManager.gridHelper.visible = false + + if (savedCameraType !== 'perspective') { + this.cameraManager.toggleCamera('perspective') + } + + const box = new THREE.Box3().setFromObject(this.modelManager.currentModel) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + const maxDim = Math.max(size.x, size.y, size.z) + const distance = maxDim * 1.5 + + const cameraPosition = new THREE.Vector3( + center.x - distance * 0.8, + center.y + distance * 0.4, + center.z + distance * 0.3 + ) + + this.cameraManager.perspectiveCamera.position.copy(cameraPosition) + this.cameraManager.perspectiveCamera.lookAt(center) + this.cameraManager.perspectiveCamera.updateProjectionMatrix() + + if (this.controlsManager.controls) { + this.controlsManager.controls.target.copy(center) + this.controlsManager.controls.update() + } + + const result = await this.sceneManager.captureScene(width, height) + return result.scene + } finally { + this.sceneManager.gridHelper.visible = savedGridVisible + + if (savedCameraType !== 'perspective') { + this.cameraManager.toggleCamera(savedCameraType) + } + this.cameraManager.setCameraState(savedState) + this.controlsManager.controls?.update() + } + } + public remove(): void { if (this.contextMenuAbortController) { this.contextMenuAbortController.abort() diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index 13095ac96..ba7c36e55 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,9 +1,34 @@ +import type Load3d from '@/extensions/core/load3d/Load3d' import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' class Load3dUtils { + static async generateThumbnailIfNeeded( + load3d: Load3d, + modelPath: string, + folderType: 'input' | 'output' + ): Promise { + const [subfolder, filename] = this.splitFilePath(modelPath) + const thumbnailFilename = this.getThumbnailFilename(filename) + + const exists = await this.fileExists( + subfolder, + thumbnailFilename, + folderType + ) + if (exists) return + + const imageData = await load3d.captureThumbnail(256, 256) + await this.uploadThumbnail( + imageData, + subfolder, + thumbnailFilename, + folderType + ) + } + static async uploadTempImage( imageData: string, prefix: string, @@ -122,6 +147,46 @@ class Load3dUtils { await Promise.all(uploadPromises) } + + static getThumbnailFilename(modelFilename: string): string { + return `${modelFilename}.png` + } + + static async fileExists( + subfolder: string, + filename: string, + type: string = 'input' + ): Promise { + try { + const url = api.apiURL(this.getResourceURL(subfolder, filename, type)) + const response = await fetch(url, { method: 'HEAD' }) + return response.ok + } catch { + return false + } + } + + static async uploadThumbnail( + imageData: string, + subfolder: string, + filename: string, + type: string = 'input' + ): Promise { + const blob = await fetch(imageData).then((r) => r.blob()) + const file = new File([blob], filename, { type: 'image/png' }) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', subfolder) + body.append('type', type) + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + return resp.status === 200 + } } export default Load3dUtils diff --git a/src/extensions/core/nightlyBadges.ts b/src/extensions/core/nightlyBadges.ts new file mode 100644 index 000000000..a453a6397 --- /dev/null +++ b/src/extensions/core/nightlyBadges.ts @@ -0,0 +1,17 @@ +import { t } from '@/i18n' +import { useExtensionService } from '@/services/extensionService' +import type { TopbarBadge } from '@/types/comfy' + +const badges: TopbarBadge[] = [ + { + text: t('nightly.badge.label'), + label: t('g.nightly'), + variant: 'warning', + tooltip: t('nightly.badge.tooltip') + } +] + +useExtensionService().registerExtension({ + name: 'Comfy.Nightly.Badges', + topbarBadges: badges +}) diff --git a/src/extensions/core/nodeTemplates.ts b/src/extensions/core/nodeTemplates.ts index 040b54526..532fbc410 100644 --- a/src/extensions/core/nodeTemplates.ts +++ b/src/extensions/core/nodeTemplates.ts @@ -32,9 +32,13 @@ import { GroupNodeConfig, GroupNodeHandler } from './groupNode' const id = 'Comfy.NodeTemplates' const file = 'comfy.templates.json' +interface NodeTemplate { + name: string + data: string +} + class ManageTemplates extends ComfyDialog { - // @ts-expect-error fixme ts strict error - templates: any[] + templates: NodeTemplate[] = [] draggedEl: HTMLElement | null saveVisualCue: number | null emptyImg: HTMLImageElement diff --git a/src/extensions/core/previewAny.ts b/src/extensions/core/previewAny.ts index d9d093702..d06ba1584 100644 --- a/src/extensions/core/previewAny.ts +++ b/src/extensions/core/previewAny.ts @@ -75,7 +75,9 @@ useExtensionService().registerExtension({ for (const previewWidget of previewWidgets) { const text = message.text ?? '' - previewWidget.value = Array.isArray(text) ? (text[0] ?? '') : text + previewWidget.value = Array.isArray(text) + ? (text?.join('\n\n') ?? '') + : text } } } diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index ae94a8609..947120467 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -4,6 +4,7 @@ import Load3D from '@/components/load3d/Load3D.vue' import { useLoad3d } from '@/composables/useLoad3d' import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema' @@ -94,6 +95,17 @@ useExtensionService().registerExtension({ const config = new Load3DConfiguration(load3d, node.properties) const loadFolder = fileInfo.type as 'input' | 'output' + + const onModelLoaded = () => { + load3d.removeEventListener('modelLoadingEnd', onModelLoaded) + void Load3dUtils.generateThumbnailIfNeeded( + load3d, + filePath, + loadFolder + ) + } + load3d.addEventListener('modelLoadingEnd', onModelLoaded) + config.configureForSaveMesh(loadFolder, filePath) } }) diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index fbe645859..c1450632b 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -1,6 +1,5 @@ import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder' -import { useChainCallback } from '@/composables/functional/useChainCallback' import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop' import { useNodeFileInput } from '@/composables/node/useNodeFileInput' import { useNodePaste } from '@/composables/node/useNodePaste' @@ -25,6 +24,17 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { api } from '../../scripts/api' import { app } from '../../scripts/app' +function updateUIWidget( + audioUIWidget: DOMWidget, + url: string = '' +) { + audioUIWidget.element.src = url + audioUIWidget.value = url + audioUIWidget.callback?.(url) + if (url) audioUIWidget.element.classList.remove('empty-audio-widget') + else audioUIWidget.element.classList.add('empty-audio-widget') +} + async function uploadFile( audioWidget: IStringWidget, audioUIWidget: DOMWidget, @@ -55,10 +65,10 @@ async function uploadFile( } if (updateNode) { - audioUIWidget.element.src = api.apiURL( - getResourceURL(...splitFilePath(path)) + updateUIWidget( + audioUIWidget, + api.apiURL(getResourceURL(...splitFilePath(path))) ) - audioWidget.value = path // Manually trigger the callback to update VueNodes audioWidget.callback?.(path) @@ -118,26 +128,18 @@ app.registerExtension({ const audios = output.audio if (!audios?.length) return const audio = audios[0] - audioUIWidget.element.src = api.apiURL( - getResourceURL( - audio.subfolder ?? '', - audio.filename ?? '', - audio.type - ) + const resourceUrl = getResourceURL( + audio.subfolder ?? '', + audio.filename ?? '', + audio.type ) - audioUIWidget.element.classList.remove('empty-audio-widget') + updateUIWidget(audioUIWidget, api.apiURL(resourceUrl)) } } - audioUIWidget.onRemove = useChainCallback( - audioUIWidget.onRemove, - () => { - if (!audioUIWidget.element) return - audioUIWidget.element.pause() - audioUIWidget.element.src = '' - audioUIWidget.element.remove() - } - ) + let value = '' + audioUIWidget.options.getValue = () => value + audioUIWidget.options.setValue = (v) => (value = v) return { widget: audioUIWidget } } @@ -156,10 +158,12 @@ app.registerExtension({ (w) => w.name === 'audioUI' ) as unknown as DOMWidget const audio = output.audio[0] - audioUIWidget.element.src = api.apiURL( - getResourceURL(audio.subfolder ?? '', audio.filename ?? '', audio.type) + const resourceUrl = getResourceURL( + audio.subfolder ?? '', + audio.filename ?? '', + audio.type ) - audioUIWidget.element.classList.remove('empty-audio-widget') + updateUIWidget(audioUIWidget, api.apiURL(resourceUrl)) } } }) @@ -183,18 +187,18 @@ app.registerExtension({ const audioUIWidget = node.widgets.find( (w) => w.name === 'audioUI' ) as unknown as DOMWidget - audioUIWidget.options.canvasOnly = true const onAudioWidgetUpdate = () => { - if (typeof audioWidget.value !== 'string') return - audioUIWidget.element.src = api.apiURL( - getResourceURL(...splitFilePath(audioWidget.value)) + updateUIWidget( + audioUIWidget, + api.apiURL( + getResourceURL(...splitFilePath(audioWidget.value ?? '')) + ) ) } // Initially load default audio file to audioUIWidget. - if (audioWidget.value) { - onAudioWidgetUpdate() - } + onAudioWidgetUpdate() + audioWidget.callback = onAudioWidgetUpdate // Load saved audio file widget values if restoring from workflow @@ -202,9 +206,7 @@ app.registerExtension({ node.onGraphConfigured = function () { // @ts-expect-error fixme ts strict error onGraphConfigured?.apply(this, arguments) - if (audioWidget.value) { - onAudioWidgetUpdate() - } + onAudioWidgetUpdate() } const handleUpload = async (files: File[]) => { @@ -328,7 +330,7 @@ app.registerExtension({ URL.revokeObjectURL(audioUIWidget.element.src) } - audioUIWidget.element.src = URL.createObjectURL(audioBlob) + updateUIWidget(audioUIWidget, URL.createObjectURL(audioBlob)) isRecording = false diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index c7f9f6eb8..879e79976 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -1,7 +1,4 @@ -import { - type CallbackParams, - useChainCallback -} from '@/composables/functional/useChainCallback' +import { useChainCallback } from '@/composables/functional/useChainCallback' import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import type { INodeInputSlot, @@ -11,7 +8,10 @@ import type { } from '@/lib/litegraph/src/litegraph' import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { + IBaseWidget, + TWidgetValue +} from '@/lib/litegraph/src/types/widgets' import type { InputSpec } from '@/schemas/nodeDefSchema' import { app } from '@/scripts/app' import { @@ -26,7 +26,7 @@ import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards' const replacePropertyName = 'Run widget replace on values' export class PrimitiveNode extends LGraphNode { - controlValues?: any[] + controlValues?: TWidgetValue[] lastType?: string static override category: string constructor(title: string) { @@ -561,7 +561,7 @@ app.registerExtension({ const origOnInputDblClick = nodeType.prototype.onInputDblClick nodeType.prototype.onInputDblClick = function ( this: LGraphNode, - ...[slot, ...args]: CallbackParams + ...[slot, ...args]: Parameters> ) { const r = origOnInputDblClick?.apply(this, [slot, ...args]) diff --git a/src/lib/litegraph/AGENTS.md b/src/lib/litegraph/AGENTS.md new file mode 100644 index 000000000..901f97f7e --- /dev/null +++ b/src/lib/litegraph/AGENTS.md @@ -0,0 +1,44 @@ +# Litegraph Guidelines + +## Code Philosophy + +- Write concise, legible, and easily maintainable code +- Avoid repetition where possible, but not at expense of legibility +- Prefer running single tests, not the whole suite, for performance + +## Code Style + +- Prefer single line `if` syntax for concise expressions +- Take advantage of `TypedArray` `subarray` when appropriate +- The `size` and `pos` properties of `Rectangle` share the same array buffer +- Prefer returning `undefined` over `null` +- Type assertions are a last resort (acceptable for legacy code interop) + +## Circular Dependencies in Tests + +**CRITICAL**: Always import from the barrel export for subgraph code: + +```typescript +// ✅ Correct - barrel import +import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph' + +// ❌ Wrong - causes circular dependency +import { LGraph } from '@/lib/litegraph/src/LGraph' +``` + +**Root cause**: `LGraph` ↔ `Subgraph` circular dependency (Subgraph extends LGraph, LGraph creates Subgraph instances). + +## Test Helpers + +```typescript +import { + createTestSubgraph, + createTestSubgraphNode +} from './fixtures/subgraphHelpers' + +function createTestSetup() { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + return { subgraph, subgraphNode } +} +``` diff --git a/src/lib/litegraph/API.md b/src/lib/litegraph/API.md index 5604f4e4d..77c76bad4 100644 --- a/src/lib/litegraph/API.md +++ b/src/lib/litegraph/API.md @@ -90,11 +90,11 @@ Example usage: ```typescript const { pointer } = this // Click / double click - executed on pointerup -pointer.onClick = e => node.executeClick(e) +pointer.onClick = (e) => node.executeClick(e) pointer.onDoubleClick = node.gotDoubleClick // Drag events - executed on pointermove -pointer.onDragStart = e => { +pointer.onDragStart = (e) => { node.isBeingDragged = true canvas.startedDragging(e) } @@ -124,20 +124,20 @@ widget.onPointerDown = function (pointer, node, canvas) { const offsetFromNode = [e.canvasX - node.pos[0], e.canvasY - node.pos[1]] // Click events - no overlap with drag events - pointer.onClick = upEvent => { + pointer.onClick = (upEvent) => { // Provides access to the whole lifecycle of events in every callback console.log(pointer.eDown) console.log(pointer.eMove ?? "Pointer didn't move") console.log(pointer.eUp) } - pointer.onDoubleClick = upEvent => this.customFunction(upEvent) + pointer.onDoubleClick = (upEvent) => this.customFunction(upEvent) // Runs once before the first onDrag event pointer.onDragStart = () => {} // Receives every movement event - pointer.onDrag = moveEvent => {} + pointer.onDrag = (moveEvent) => {} // The pointerup event of a drag - pointer.onDragEnd = upEvent => {} + pointer.onDragEnd = (upEvent) => {} // Semantics of a "finally" block (try/catch). Once set, the block always executes. pointer.finally = () => {} diff --git a/src/lib/litegraph/CLAUDE.md b/src/lib/litegraph/CLAUDE.md index 68f8bea95..dd27f8fab 100644 --- a/src/lib/litegraph/CLAUDE.md +++ b/src/lib/litegraph/CLAUDE.md @@ -1,62 +1,4 @@ -- This codebase has extensive eslint autofix rules and IDEs are configured to use eslint as the format on save tool. Run ESLint instead of manually figuring out whitespace fixes or other trivial style concerns. Review the results and correct any remaining eslint errors. -- Take advantage of `TypedArray` `subarray` when appropriate. -- The `size` and `pos` properties of `Rectangle` share the same array buffer (`subarray`); they may be used to set the rectangles size and position. -- Prefer single line `if` syntax over adding curly braces, when the statement has a very concise expression and concise, single line statement. -- Do not replace `&&=` or `||=` with `=` when there is no reason to do so. If you do find a reason to remove either `&&=` or `||=`, leave a comment explaining why the removal occurred. -- You are allowed to research code on https://developer.mozilla.org/ and https://stackoverflow.com without asking. -- When adding features, always write vitest unit tests using cursor rules in @.cursor -- When writing methods, prefer returning idiomatic JavaScript `undefined` over `null`. + -# Bash commands - -- `pnpm typecheck` Run the typechecker -- `pnpm build` Build the project -- `pnpm lint:fix` Run ESLint - -# Code style - -- Always prefer best practices when writing code. -- Write using concise, legible, and easily maintainable code. -- Avoid repetition where possible, but not at the expense of code legibility. -- Type assertions are an absolute last resort. In almost all cases, they are a crutch that leads to brittle code. - -# Workflow - -- Be sure to typecheck when you're done making a series of code changes -- Prefer running single tests, and not the whole test suite, for performance - -# Testing Guidelines - -## Avoiding Circular Dependencies in Tests - -**CRITICAL**: When writing tests for subgraph-related code, always import from the barrel export to avoid circular dependency issues: - -```typescript -// ✅ CORRECT - Use barrel import -import { LGraph, Subgraph, SubgraphNode } from "@/lib/litegraph/src/litegraph" - -// ❌ WRONG - Direct imports cause circular dependency -import { LGraph } from "@/lib/litegraph/src/LGraph" -import { Subgraph } from "@/lib/litegraph/src/subgraph/Subgraph" -import { SubgraphNode } from "@/lib/litegraph/src/subgraph/SubgraphNode" -``` - -**Root cause**: `LGraph` and `Subgraph` have a circular dependency: -- `LGraph.ts` imports `Subgraph` (creates instances with `new Subgraph()`) -- `Subgraph.ts` extends `LGraph` - -The barrel export (`@/litegraph`) handles this properly, but direct imports cause module loading failures. - -## Test Setup for Subgraphs - -Use the provided test helpers for consistent setup: - -```typescript -import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers" - -function createTestSetup() { - const subgraph = createTestSubgraph() - const subgraphNode = createTestSubgraphNode(subgraph) - return { subgraph, subgraphNode } -} -``` +@AGENTS.md diff --git a/src/lib/litegraph/CONTRIBUTING.md b/src/lib/litegraph/CONTRIBUTING.md index 45043616b..d00a03b00 100755 --- a/src/lib/litegraph/CONTRIBUTING.md +++ b/src/lib/litegraph/CONTRIBUTING.md @@ -1,9 +1,9 @@ # Contribution Rules + There are some simple rules that everyone should follow: ### Do not commit files from build folder + > I usually have horrible merge conflicts when I upload the build version that take me too much time to solve, but I want to keep the build version in the repo, so I guess it would be better if only one of us does the built, which would be me. > https://github.com/jagenjo/litegraph.js/pull/155#issuecomment-656602861 -Those files will be updated by owner. - - +> Those files will be updated by owner. diff --git a/src/lib/litegraph/README.md b/src/lib/litegraph/README.md index 998532a82..a1f80e202 100755 --- a/src/lib/litegraph/README.md +++ b/src/lib/litegraph/README.md @@ -1,173 +1,173 @@ -# @ComfyOrg/litegraph - -This is the litegraph version used in [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend). - -It is a fork of the original `litegraph.js`. Some APIs may by unchanged, however it is largely incompatible with the original. - -Some early highlights: - -- Accumulated comfyUI custom changes (2024-01 ~ 2024-05) (https://github.com/Comfy-Org/litegraph.js/pull/1) -- Type schema change for ComfyUI_frontend TS migration (https://github.com/Comfy-Org/litegraph.js/pull/3) -- Zoom fix (https://github.com/Comfy-Org/litegraph.js/pull/7) -- Emit search box triggering custom events () -- Truncate overflowing combo widget text () -- Sort node based on ID on graph serialization () -- Fix empty input not used when connecting links () -- Batch output connection move/disconnect () -- And now with hundreds more... - -# Usage - -This library is included as a git subtree in the ComfyUI frontend project at `src/lib/litegraph`. - -# litegraph.js - -A TypeScript library to create graphs in the browser similar to Unreal Blueprints. - -
- -Description of the original litegraph.js - -A library in Javascript to create graphs in the browser similar to Unreal Blueprints. Nodes can be programmed easily and it includes an editor to construct and tests the graphs. - -It can be integrated easily in any existing web applications and graphs can be run without the need of the editor. - -
- -![Node Graph](imgs/node_graph_example.png "Node graph example") - -## Features - -- Renders on Canvas2D (zoom in/out and panning, easy to render complex interfaces, can be used inside a WebGLTexture) -- Easy to use editor (searchbox, keyboard shortcuts, multiple selection, context menu, ...) -- Optimized to support hundreds of nodes per graph (on editor but also on execution) -- Customizable theme (colors, shapes, background) -- Callbacks to personalize every action/drawing/event of nodes -- Graphs can be executed in NodeJS -- Highly customizable nodes (color, shape, widgets, custom rendering) -- Easy to integrate in any JS application (one single file, no dependencies) -- Typescript support - -## Integration - -This library is integrated as a git subtree in the ComfyUI frontend project. To use it in your code: - -```typescript -import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph' -``` - -## How to code a new Node type - -Here is an example of how to build a node that sums two inputs: - -```ts -import { LiteGraph, LGraphNode } from "./litegraph" - -class MyAddNode extends LGraphNode { - // Name to show - title = "Sum" - - constructor() { - this.addInput("A", "number") - this.addInput("B", "number") - this.addOutput("A+B", "number") - this.properties.precision = 1 - } - - // Function to call when the node is executed - onExecute() { - var A = this.getInputData(0) - if (A === undefined) A = 0 - var B = this.getInputData(1) - if (B === undefined) B = 0 - this.setOutputData(0, A + B) - } -} - -// Register the node type -LiteGraph.registerNodeType("basic/sum", MyAddNode) -``` - -## Server side - -It also works server-side using NodeJS although some nodes do not work in server (audio, graphics, input, etc). - -```ts -import { LiteGraph, LGraph } from "./litegraph.js" - -const graph = new LGraph() - -const firstNode = LiteGraph.createNode("basic/sum") -graph.add(firstNode) - -const secondNode = LiteGraph.createNode("basic/sum") -graph.add(secondNode) - -firstNode.connect(0, secondNode, 1) - -graph.start() -``` - -## Projects using it - -### [ComfyUI](https://github.com/comfyanonymous/ComfyUI) - -![ComfyUI default workflow](https://github.com/comfyanonymous/ComfyUI/blob/6efe561c2a7321501b1b27f47039c7616dda1860/comfyui_screenshot.png "ComfyUI default workflow") - -### Projects using the original litegraph.js - -
- -Click to expand - -### [webglstudio.org](http://webglstudio.org) - -![WebGLStudio](imgs/webglstudio.gif "WebGLStudio") - -### [MOI Elephant](http://moiscript.weebly.com/elephant-systegraveme-nodal.html) - -![MOI Elephant](imgs/elephant.gif "MOI Elephant") - -### Mynodes - -![MyNodes](imgs/mynodes.png "MyNodes") - -
- -## Feedback - -Please [open an issue](https://github.com/Comfy-Org/litegraph.js/issues/) on the GitHub repo. - -# Development - -Litegraph has no runtime dependencies. The build tooling has been tested on Node.JS 20.18.x - -## Releasing - -Use GitHub actions to release normal versions. - -1. Run the `Release a New Version` action, selecting the version increment type -1. Merge the resolution PR -1. A GitHub release is automatically published on merge - -### Pre-release - -The action directly translates `Version increment type` to the pnpm version command. `Pre-release ID (suffix)` is the option for the `--preid` argument. - -e.g. Use `prerelease` increment type to automatically bump the patch version and create a pre-release version. Subsequent runs of prerelease will update the prerelease version only. -Use `patch` when ready to remove the pre-release suffix. - -## Contributors - -You can find the [current list of contributors](https://github.com/Comfy-Org/litegraph.js/graphs/contributors) on GitHub. - -### Contributors (pre-fork) - -- atlasan -- kriffe -- rappestad -- InventivetalentDev -- NateScarlet -- coderofsalvation -- ilyabesk -- gausszhou +# @ComfyOrg/litegraph + +This is the litegraph version used in [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend). + +It is a fork of the original `litegraph.js`. Some APIs may by unchanged, however it is largely incompatible with the original. + +Some early highlights: + +- Accumulated comfyUI custom changes (2024-01 ~ 2024-05) (https://github.com/Comfy-Org/litegraph.js/pull/1) +- Type schema change for ComfyUI_frontend TS migration (https://github.com/Comfy-Org/litegraph.js/pull/3) +- Zoom fix (https://github.com/Comfy-Org/litegraph.js/pull/7) +- Emit search box triggering custom events () +- Truncate overflowing combo widget text () +- Sort node based on ID on graph serialization () +- Fix empty input not used when connecting links () +- Batch output connection move/disconnect () +- And now with hundreds more... + +# Usage + +This library is included as a git subtree in the ComfyUI frontend project at `src/lib/litegraph`. + +# litegraph.js + +A TypeScript library to create graphs in the browser similar to Unreal Blueprints. + +
+ +Description of the original litegraph.js + +A library in Javascript to create graphs in the browser similar to Unreal Blueprints. Nodes can be programmed easily and it includes an editor to construct and tests the graphs. + +It can be integrated easily in any existing web applications and graphs can be run without the need of the editor. + +
+ +![Node Graph](imgs/node_graph_example.png 'Node graph example') + +## Features + +- Renders on Canvas2D (zoom in/out and panning, easy to render complex interfaces, can be used inside a WebGLTexture) +- Easy to use editor (searchbox, keyboard shortcuts, multiple selection, context menu, ...) +- Optimized to support hundreds of nodes per graph (on editor but also on execution) +- Customizable theme (colors, shapes, background) +- Callbacks to personalize every action/drawing/event of nodes +- Graphs can be executed in NodeJS +- Highly customizable nodes (color, shape, widgets, custom rendering) +- Easy to integrate in any JS application (one single file, no dependencies) +- Typescript support + +## Integration + +This library is integrated as a git subtree in the ComfyUI frontend project. To use it in your code: + +```typescript +import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph' +``` + +## How to code a new Node type + +Here is an example of how to build a node that sums two inputs: + +```ts +import { LiteGraph, LGraphNode } from './litegraph' + +class MyAddNode extends LGraphNode { + // Name to show + title = 'Sum' + + constructor() { + this.addInput('A', 'number') + this.addInput('B', 'number') + this.addOutput('A+B', 'number') + this.properties.precision = 1 + } + + // Function to call when the node is executed + onExecute() { + var A = this.getInputData(0) + if (A === undefined) A = 0 + var B = this.getInputData(1) + if (B === undefined) B = 0 + this.setOutputData(0, A + B) + } +} + +// Register the node type +LiteGraph.registerNodeType('basic/sum', MyAddNode) +``` + +## Server side + +It also works server-side using NodeJS although some nodes do not work in server (audio, graphics, input, etc). + +```ts +import { LiteGraph, LGraph } from './litegraph.js' + +const graph = new LGraph() + +const firstNode = LiteGraph.createNode('basic/sum') +graph.add(firstNode) + +const secondNode = LiteGraph.createNode('basic/sum') +graph.add(secondNode) + +firstNode.connect(0, secondNode, 1) + +graph.start() +``` + +## Projects using it + +### [ComfyUI](https://github.com/comfyanonymous/ComfyUI) + +![ComfyUI default workflow](https://github.com/comfyanonymous/ComfyUI/blob/6efe561c2a7321501b1b27f47039c7616dda1860/comfyui_screenshot.png 'ComfyUI default workflow') + +### Projects using the original litegraph.js + +
+ +Click to expand + +### [webglstudio.org](http://webglstudio.org) + +![WebGLStudio](imgs/webglstudio.gif 'WebGLStudio') + +### [MOI Elephant](http://moiscript.weebly.com/elephant-systegraveme-nodal.html) + +![MOI Elephant](imgs/elephant.gif 'MOI Elephant') + +### Mynodes + +![MyNodes](imgs/mynodes.png 'MyNodes') + +
+ +## Feedback + +Please [open an issue](https://github.com/Comfy-Org/litegraph.js/issues/) on the GitHub repo. + +# Development + +Litegraph has no runtime dependencies. The build tooling has been tested on Node.JS 20.18.x + +## Releasing + +Use GitHub actions to release normal versions. + +1. Run the `Release a New Version` action, selecting the version increment type +1. Merge the resolution PR +1. A GitHub release is automatically published on merge + +### Pre-release + +The action directly translates `Version increment type` to the pnpm version command. `Pre-release ID (suffix)` is the option for the `--preid` argument. + +e.g. Use `prerelease` increment type to automatically bump the patch version and create a pre-release version. Subsequent runs of prerelease will update the prerelease version only. +Use `patch` when ready to remove the pre-release suffix. + +## Contributors + +You can find the [current list of contributors](https://github.com/Comfy-Org/litegraph.js/graphs/contributors) on GitHub. + +### Contributors (pre-fork) + +- atlasan +- kriffe +- rappestad +- InventivetalentDev +- NateScarlet +- coderofsalvation +- ilyabesk +- gausszhou diff --git a/src/lib/litegraph/public/css/litegraph.css b/src/lib/litegraph/public/css/litegraph.css index 83b36dd98..0ed31874f 100644 --- a/src/lib/litegraph/public/css/litegraph.css +++ b/src/lib/litegraph/public/css/litegraph.css @@ -613,7 +613,7 @@ margin: 0 10px; padding: 2px 5px; } -.litegraph .graphdialog input[type="checkbox"] { +.litegraph .graphdialog input[type='checkbox'] { width: 16px; height: 16px; } diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 04f2a4a4f..95bd5b3ad 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -102,16 +102,23 @@ export interface LGraphConfig { links_ontop?: boolean } +export interface GroupNodeConfigEntry { + input?: Record + output?: Record +} + export interface GroupNodeWorkflowData { external: (number | string)[][] links: SerialisedLLinkArray[] nodes: { index?: number type?: string + title?: string inputs?: unknown[] outputs?: unknown[] + widgets_values?: unknown[] }[] - config?: Record + config?: Record } export interface LGraphExtra extends Dictionary { @@ -1571,8 +1578,21 @@ export class LGraph // Inputs, outputs, and links const links = internalLinks.map((x) => x.asSerialisable()) - const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links) - const outputs = mapSubgraphOutputsAndLinks(resolvedOutputLinks, links) + + const internalReroutes = new Map([...reroutes].map((r) => [r.id, r])) + const externalReroutes = new Map( + [...this.reroutes].filter(([id]) => !internalReroutes.has(id)) + ) + const inputs = mapSubgraphInputsAndLinks( + resolvedInputLinks, + links, + internalReroutes + ) + const outputs = mapSubgraphOutputsAndLinks( + resolvedOutputLinks, + links, + externalReroutes + ) // Prepare subgraph data const data = { @@ -1714,10 +1734,10 @@ export class LGraph // Reconnect output links in parent graph i = 0 for (const [, connections] of outputsGroupedByOutput.entries()) { - // Special handling: Subgraph output node i++ for (const connection of connections) { const { input, inputNode, link, subgraphOutput } = connection + // Special handling: Subgraph output node if (link.target_id === SUBGRAPH_OUTPUT_ID) { link.origin_id = subgraphNode.id link.origin_slot = i - 1 @@ -2013,33 +2033,50 @@ export class LGraph while (parentId) { instance.parentId = parentId instance = this.reroutes.get(parentId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) parentId = instance.parentId } } + if (!instance) continue parentId = newLink.iparent while (parentId) { const migratedId = rerouteIdMap.get(parentId) - if (!migratedId) throw new Error('Broken Id link when unpacking') + if (!migratedId) { + console.error('Broken Id link when unpacking') + break + } instance.parentId = migratedId instance = this.reroutes.get(migratedId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) const oldReroute = subgraphNode.subgraph.reroutes.get(parentId) - if (!oldReroute) throw new Error('Broken Id link when unpacking') + if (!oldReroute) { + console.error('Broken Id link when unpacking') + break + } parentId = oldReroute.parentId } + if (!instance) break if (!newLink.externalFirst) { parentId = newLink.eparent while (parentId) { instance.parentId = parentId instance = this.reroutes.get(parentId) - if (!instance) throw new Error('Broken Id link when unpacking') + if (!instance) { + console.error('Broken Id link when unpacking') + break + } if (instance.linkIds.has(linkInstance.id)) throw new Error('Infinite parentId loop') instance.linkIds.add(linkInstance.id) @@ -2545,6 +2582,7 @@ export class Subgraph this.inputNode.configure(data.inputNode) this.outputNode.configure(data.outputNode) + for (const node of this.nodes) node.updateComputedDisabled() } override configure( @@ -2565,6 +2603,10 @@ export class Subgraph } addInput(name: string, type: string): SubgraphInput { + if (name === null || type === null) { + throw new Error('Name and type are required for subgraph input') + } + this.events.dispatch('adding-input', { name, type }) const input = new SubgraphInput( @@ -2583,6 +2625,10 @@ export class Subgraph } addOutput(name: string, type: string): SubgraphOutput { + if (name === null || type === null) { + throw new Error('Name and type are required for subgraph output') + } + this.events.dispatch('adding-output', { name, type }) const output = new SubgraphOutput( diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 3129324e3..37c0950a2 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1350,12 +1350,12 @@ export class LGraphCanvas implements CustomEventDispatcher }) function inner_clicked( - this: ContextMenu, + this: ContextMenuDivElement, v?: string | IContextMenuValue ) { if (!node || typeof v === 'string' || !v?.value) return - const rect = this.root.getBoundingClientRect() + const rect = this.getBoundingClientRect() canvas.showEditPropertyValue(node, v.value, { position: [rect.left, rect.top] }) @@ -3187,7 +3187,9 @@ export class LGraphCanvas implements CustomEventDispatcher } // get node over - const node = graph.getNodeOnPos(x, y, this.visible_nodes) + const node = LiteGraph.vueNodesMode + ? null + : graph.getNodeOnPos(x, y, this.visible_nodes) const dragRect = this.dragging_rectangle if (dragRect) { @@ -4281,6 +4283,12 @@ export class LGraphCanvas implements CustomEventDispatcher item.selected = true this.selectedItems.add(item) this.state.selectionChanged = true + + if (item instanceof LGraphGroup) { + item.recomputeInsideNodes() + return + } + if (!(item instanceof LGraphNode)) return // Node-specific handling diff --git a/src/lib/litegraph/src/LGraphGroup.test.ts b/src/lib/litegraph/src/LGraphGroup.test.ts index 589e5a958..64b437b6a 100644 --- a/src/lib/litegraph/src/LGraphGroup.test.ts +++ b/src/lib/litegraph/src/LGraphGroup.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from 'vitest' -import { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { LGraph, LGraphGroup } from '@/lib/litegraph/src/litegraph' import { test } from './__fixtures__/testExtensions' @@ -9,4 +9,72 @@ describe('LGraphGroup', () => { const link = new LGraphGroup('title', 929) expect(link.serialize()).toMatchSnapshot('Basic') }) + + describe('recomputeInsideNodes', () => { + test('uses visited set to avoid redundant computation', () => { + const graph = new LGraph() + + // Create 4 nested groups: outer -> mid1 -> mid2 -> inner + const outer = new LGraphGroup('outer') + outer.pos = [0, 0] + outer.size = [400, 400] + graph.add(outer) + + const mid1 = new LGraphGroup('mid1') + mid1.pos = [10, 10] + mid1.size = [300, 300] + graph.add(mid1) + + const mid2 = new LGraphGroup('mid2') + mid2.pos = [20, 20] + mid2.size = [200, 200] + graph.add(mid2) + + const inner = new LGraphGroup('inner') + inner.pos = [30, 30] + inner.size = [100, 100] + graph.add(inner) + + // Track the visited set to verify each group is only fully processed once + const visited = new Set() + outer.recomputeInsideNodes(100, visited) + + // All nested groups should be in the visited set + expect(visited.has(outer.id)).toBe(true) + expect(visited.has(mid1.id)).toBe(true) + expect(visited.has(mid2.id)).toBe(true) + expect(visited.has(inner.id)).toBe(true) + expect(visited.size).toBe(4) + + // Verify children relationships are correct + expect(outer.children.has(mid1)).toBe(true) + expect(outer.children.has(mid2)).toBe(true) + expect(outer.children.has(inner)).toBe(true) + expect(mid1.children.has(mid2)).toBe(true) + expect(mid1.children.has(inner)).toBe(true) + expect(mid2.children.has(inner)).toBe(true) + }) + + test('respects maxDepth limit', () => { + const graph = new LGraph() + + const outer = new LGraphGroup('outer') + outer.pos = [0, 0] + outer.size = [300, 300] + graph.add(outer) + + const inner = new LGraphGroup('inner') + inner.pos = [10, 10] + inner.size = [100, 100] + graph.add(inner) + + // With maxDepth=1, inner group is added as child but not processed + outer.recomputeInsideNodes(1) + + // outer should have inner as a child + expect(outer.children.has(inner)).toBe(true) + // inner should not have computed its own children (it was never processed) + expect(inner.children.size).toBe(0) + }) + }) }) diff --git a/src/lib/litegraph/src/LGraphGroup.ts b/src/lib/litegraph/src/LGraphGroup.ts index 9e14cf277..ee3197d43 100644 --- a/src/lib/litegraph/src/LGraphGroup.ts +++ b/src/lib/litegraph/src/LGraphGroup.ts @@ -241,8 +241,21 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { return this.pinned ? false : snapPoint(this.pos, snapTo) } - recomputeInsideNodes(): void { + /** + * Recomputes which items (nodes, reroutes, nested groups) are inside this group. + * Recursively processes nested groups to ensure their children are also computed. + * @param maxDepth Maximum recursion depth for nested groups. Use 1 to skip nested group computation. + * @param visited Set of already visited group IDs to prevent redundant computation. + */ + recomputeInsideNodes( + maxDepth: number = 100, + visited: Set = new Set() + ): void { if (!this.graph) throw new NullGraphError() + if (maxDepth <= 0 || visited.has(this.id)) return + + visited.add(this.id) + const { nodes, reroutes, groups } = this.graph const children = this._children this._nodes.length = 0 @@ -261,10 +274,16 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute) } - // Move groups we wholly contain + // Move groups we wholly contain and recursively compute their children + const containedGroups: LGraphGroup[] = [] for (const group of groups) { - if (containsRect(this._bounding, group._bounding)) children.add(group) + if (group !== this && containsRect(this._bounding, group._bounding)) { + children.add(group) + containedGroups.push(group) + } } + for (const group of containedGroups) + group.recomputeInsideNodes(maxDepth - 1, visited) groups.sort((a, b) => { if (a === this) { diff --git a/src/lib/litegraph/src/LGraphNode.test.ts b/src/lib/litegraph/src/LGraphNode.test.ts index 98ef533f6..5b498dac3 100644 --- a/src/lib/litegraph/src/LGraphNode.test.ts +++ b/src/lib/litegraph/src/LGraphNode.test.ts @@ -14,6 +14,11 @@ import { } from '@/lib/litegraph/src/litegraph' import { test } from './__fixtures__/testExtensions' +import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils' + +interface NodeConstructorWithSlotOffset { + slot_start_y?: number +} function getMockISerialisedNode( data: Partial @@ -132,6 +137,13 @@ describe('LGraphNode', () => { expect(node.id).toEqual(1) expect(node.outputs.length).toEqual(1) }) + test('should not allow configuring id to -1', () => { + const graph = new LGraph() + const node = new LGraphNode('TestNode') + graph.add(node) + node.configure(getMockISerialisedNode({ id: -1 })) + expect(node.id).not.toBe(-1) + }) describe('Disconnect I/O Slots', () => { test('should disconnect input correctly', () => { @@ -297,16 +309,10 @@ describe('LGraphNode', () => { describe('getInputPos and getOutputPos', () => { test('should handle collapsed nodes correctly', () => { - const node = new LGraphNode('TestNode') as unknown as Omit< - LGraphNode, - 'boundingRect' - > & { boundingRect: Float64Array } + const node = createMockLGraphNodeWithArrayBoundingRect('TestNode') node.pos = [100, 100] node.size = [100, 100] - node.boundingRect[0] = 100 - node.boundingRect[1] = 100 - node.boundingRect[2] = 100 - node.boundingRect[3] = 100 + node.updateArea() node.configure( getMockISerialisedNode({ id: 1, @@ -366,16 +372,10 @@ describe('LGraphNode', () => { }) test('should detect input slots correctly', () => { - const node = new LGraphNode('TestNode') as unknown as Omit< - LGraphNode, - 'boundingRect' - > & { boundingRect: Float64Array } + const node = createMockLGraphNodeWithArrayBoundingRect('TestNode') node.pos = [100, 100] node.size = [100, 100] - node.boundingRect[0] = 100 - node.boundingRect[1] = 100 - node.boundingRect[2] = 200 - node.boundingRect[3] = 200 + node.updateArea() node.configure( getMockISerialisedNode({ id: 1, @@ -398,16 +398,10 @@ describe('LGraphNode', () => { }) test('should detect output slots correctly', () => { - const node = new LGraphNode('TestNode') as unknown as Omit< - LGraphNode, - 'boundingRect' - > & { boundingRect: Float64Array } + const node = createMockLGraphNodeWithArrayBoundingRect('TestNode') node.pos = [100, 100] node.size = [100, 100] - node.boundingRect[0] = 100 - node.boundingRect[1] = 100 - node.boundingRect[2] = 200 - node.boundingRect[3] = 200 + node.updateArea() node.configure( getMockISerialisedNode({ id: 1, @@ -431,16 +425,10 @@ describe('LGraphNode', () => { }) test('should prioritize input slots over output slots', () => { - const node = new LGraphNode('TestNode') as unknown as Omit< - LGraphNode, - 'boundingRect' - > & { boundingRect: Float64Array } + const node = createMockLGraphNodeWithArrayBoundingRect('TestNode') node.pos = [100, 100] node.size = [100, 100] - node.boundingRect[0] = 100 - node.boundingRect[1] = 100 - node.boundingRect[2] = 200 - node.boundingRect[3] = 200 + node.updateArea() node.configure( getMockISerialisedNode({ id: 1, @@ -632,7 +620,8 @@ describe('LGraphNode', () => { } node.inputs = [inputSlot, inputSlot2] const slotIndex = 0 - const nodeOffsetY = (node.constructor as any).slot_start_y || 0 + const nodeOffsetY = + (node.constructor as NodeConstructorWithSlotOffset).slot_start_y || 0 const expectedY = 200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5 @@ -644,7 +633,7 @@ describe('LGraphNode', () => { }) test('should return default vertical position including slot_start_y when defined', () => { - ;(node.constructor as any).slot_start_y = 25 + ;(node.constructor as NodeConstructorWithSlotOffset).slot_start_y = 25 node.flags.collapsed = false node.inputs = [inputSlot] const slotIndex = 0 @@ -653,7 +642,7 @@ describe('LGraphNode', () => { 200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5 expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY]) - delete (node.constructor as any).slot_start_y + delete (node.constructor as NodeConstructorWithSlotOffset).slot_start_y }) test('should not overwrite onMouseDown prototype', () => { expect(Object.prototype.hasOwnProperty.call(node, 'onMouseDown')).toEqual( diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 1ff503cdc..d4d50537c 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -785,6 +785,7 @@ export class LGraphNode if (this.graph) { this.graph._version++ } + if (info.id === -1) info.id = this.id for (const j in info) { if (j == 'properties') { // i don't want to clone properties, I want to reuse the old container diff --git a/src/lib/litegraph/src/LGraphNodeProperties.test.ts b/src/lib/litegraph/src/LGraphNodeProperties.test.ts index aca6fe391..1c523945c 100644 --- a/src/lib/litegraph/src/LGraphNodeProperties.test.ts +++ b/src/lib/litegraph/src/LGraphNodeProperties.test.ts @@ -1,22 +1,25 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties' +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + createMockLGraph, + createMockLGraphNode +} from '@/utils/__tests__/litegraphTestUtils' describe('LGraphNodeProperties', () => { - let mockNode: any - let mockGraph: any + let mockNode: LGraphNode + let mockGraph: LGraph beforeEach(() => { - mockGraph = { - trigger: vi.fn() - } + mockGraph = createMockLGraph() - mockNode = { + mockNode = createMockLGraphNode({ id: 123, title: 'Test Node', flags: {}, graph: mockGraph - } + }) }) describe('property tracking', () => { diff --git a/src/lib/litegraph/src/LGraphNodeProperties.ts b/src/lib/litegraph/src/LGraphNodeProperties.ts index ae51d3739..9df938298 100644 --- a/src/lib/litegraph/src/LGraphNodeProperties.ts +++ b/src/lib/litegraph/src/LGraphNodeProperties.ts @@ -13,7 +13,9 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [ 'flags.pinned', 'mode', 'color', - 'bgcolor' + 'bgcolor', + 'shape', + 'showAdvanced' ] /** * Manages node properties with optional change tracking and instrumentation. diff --git a/src/lib/litegraph/src/__fixtures__/assets/linkedNodes.json b/src/lib/litegraph/src/__fixtures__/assets/linkedNodes.json index 5eed02368..181aa6bd6 100644 --- a/src/lib/litegraph/src/__fixtures__/assets/linkedNodes.json +++ b/src/lib/litegraph/src/__fixtures__/assets/linkedNodes.json @@ -7,14 +7,8 @@ { "id": 2, "type": "VAEDecode", - "pos": [ - 63.44815444946289, - 178.71633911132812 - ], - "size": [ - 210, - 46 - ], + "pos": [63.44815444946289, 178.71633911132812], + "size": [210, 46], "flags": {}, "order": 0, "mode": 0, @@ -34,9 +28,7 @@ { "name": "IMAGE", "type": "IMAGE", - "links": [ - 2 - ] + "links": [2] } ], "properties": { @@ -47,14 +39,8 @@ { "id": 3, "type": "SaveImage", - "pos": [ - 419.36920166015625, - 179.71388244628906 - ], - "size": [ - 226.3714141845703, - 58 - ], + "pos": [419.36920166015625, 179.71388244628906], + "size": [226.3714141845703, 58], "flags": {}, "order": 1, "mode": 0, @@ -67,21 +53,10 @@ ], "outputs": [], "properties": {}, - "widgets_values": [ - "ComfyUI" - ] + "widgets_values": ["ComfyUI"] } ], - "links": [ - [ - 2, - 2, - 0, - 3, - 0, - "IMAGE" - ] - ], + "links": [[2, 2, 0, 3, 0, "IMAGE"]], "groups": [], "config": {}, "extra": { @@ -93,4 +68,4 @@ ] }, "version": 0.4 -} \ No newline at end of file +} diff --git a/src/lib/litegraph/src/__fixtures__/assets/reroutesComplex.json b/src/lib/litegraph/src/__fixtures__/assets/reroutesComplex.json index 941228baf..c164fcc4b 100644 --- a/src/lib/litegraph/src/__fixtures__/assets/reroutesComplex.json +++ b/src/lib/litegraph/src/__fixtures__/assets/reroutesComplex.json @@ -1 +1,275 @@ -{"id":"e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f","revision":0,"last_node_id":9,"last_link_id":12,"nodes":[{"id":3,"type":"InvertMask","pos":[390,270],"size":[140,26],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":3}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":7,"type":"InvertMask","pos":[390,560],"size":[140,26],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":10}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":8,"type":"InvertMask","pos":[390,640],"size":[140,26],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":9}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":5,"type":"InvertMask","pos":[390,480],"size":[140,26],"flags":{"collapsed":false},"order":5,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":11}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":6,"type":"InvertMask","pos":[390,400],"size":[140,26],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":12}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":4,"type":"InvertMask","pos":[50,640],"size":[140,26],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[9,10,11,12]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":2,"type":"InvertMask","pos":[390,180],"size":[140,26],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":2}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":1,"type":"InvertMask","pos":[50,170],"size":[140,26],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2,3]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]},{"id":9,"type":"InvertMask","pos":[50,410],"size":[140,26],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":null}],"outputs":[{"localized_name":"MASK","name":"MASK","type":"MASK","links":[]}],"properties":{"Node name for S&R":"InvertMask"},"widgets_values":[]}],"links":[[2,1,0,2,0,"MASK"],[3,1,0,3,0,"MASK"],[9,4,0,8,0,"MASK"],[10,4,0,7,0,"MASK"],[11,4,0,5,0,"MASK"],[12,4,0,6,0,"MASK"]],"floatingLinks":[{"id":6,"origin_id":1,"origin_slot":0,"target_id":-1,"target_slot":-1,"type":"MASK","parentId":1}],"groups":[],"config":{},"extra":{"ds":{"scale":1,"offset":[0,0]},"linkExtensions":[{"id":2,"parentId":3},{"id":3,"parentId":3},{"id":9,"parentId":12},{"id":10,"parentId":15},{"id":11,"parentId":7},{"id":12,"parentId":7}],"reroutes":[{"id":1,"parentId":2,"pos":[340,160],"linkIds":[],"floating":{"slotType":"output"}},{"id":2,"parentId":4,"pos":[290,190],"linkIds":[2,3]},{"id":3,"parentId":2,"pos":[350,220],"linkIds":[2,3]},{"id":4,"pos":[250,190],"linkIds":[2,3]},{"id":6,"parentId":8,"pos":[300,450],"linkIds":[11,12]},{"id":7,"parentId":6,"pos":[350,450],"linkIds":[11,12]},{"id":8,"parentId":13,"pos":[250,450],"linkIds":[11,12]},{"id":10,"pos":[250,650],"linkIds":[9,10,11,12]},{"id":11,"parentId":10,"pos":[300,650],"linkIds":[9]},{"id":12,"parentId":11,"pos":[350,650],"linkIds":[9]},{"id":13,"parentId":10,"pos":[250,570],"linkIds":[10,11,12]},{"id":14,"parentId":13,"pos":[300,570],"linkIds":[10]},{"id":15,"parentId":14,"pos":[350,570],"linkIds":[10]}]},"version":0.4} \ No newline at end of file +{ + "id": "e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f", + "revision": 0, + "last_node_id": 9, + "last_link_id": 12, + "nodes": [ + { + "id": 3, + "type": "InvertMask", + "pos": [390, 270], + "size": [140, 26], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 3 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 7, + "type": "InvertMask", + "pos": [390, 560], + "size": [140, 26], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 10 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 8, + "type": "InvertMask", + "pos": [390, 640], + "size": [140, 26], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 9 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 5, + "type": "InvertMask", + "pos": [390, 480], + "size": [140, 26], + "flags": { "collapsed": false }, + "order": 5, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 11 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 6, + "type": "InvertMask", + "pos": [390, 400], + "size": [140, 26], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 12 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 4, + "type": "InvertMask", + "pos": [50, 640], + "size": [140, 26], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [9, 10, 11, 12] + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 2, + "type": "InvertMask", + "pos": [390, 180], + "size": [140, 26], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { "localized_name": "mask", "name": "mask", "type": "MASK", "link": 2 } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 1, + "type": "InvertMask", + "pos": [50, 170], + "size": [140, 26], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [2, 3] + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + }, + { + "id": 9, + "type": "InvertMask", + "pos": [50, 410], + "size": [140, 26], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [] + } + ], + "properties": { "Node name for S&R": "InvertMask" }, + "widgets_values": [] + } + ], + "links": [ + [2, 1, 0, 2, 0, "MASK"], + [3, 1, 0, 3, 0, "MASK"], + [9, 4, 0, 8, 0, "MASK"], + [10, 4, 0, 7, 0, "MASK"], + [11, 4, 0, 5, 0, "MASK"], + [12, 4, 0, 6, 0, "MASK"] + ], + "floatingLinks": [ + { + "id": 6, + "origin_id": 1, + "origin_slot": 0, + "target_id": -1, + "target_slot": -1, + "type": "MASK", + "parentId": 1 + } + ], + "groups": [], + "config": {}, + "extra": { + "ds": { "scale": 1, "offset": [0, 0] }, + "linkExtensions": [ + { "id": 2, "parentId": 3 }, + { "id": 3, "parentId": 3 }, + { "id": 9, "parentId": 12 }, + { "id": 10, "parentId": 15 }, + { "id": 11, "parentId": 7 }, + { "id": 12, "parentId": 7 } + ], + "reroutes": [ + { + "id": 1, + "parentId": 2, + "pos": [340, 160], + "linkIds": [], + "floating": { "slotType": "output" } + }, + { "id": 2, "parentId": 4, "pos": [290, 190], "linkIds": [2, 3] }, + { "id": 3, "parentId": 2, "pos": [350, 220], "linkIds": [2, 3] }, + { "id": 4, "pos": [250, 190], "linkIds": [2, 3] }, + { "id": 6, "parentId": 8, "pos": [300, 450], "linkIds": [11, 12] }, + { "id": 7, "parentId": 6, "pos": [350, 450], "linkIds": [11, 12] }, + { "id": 8, "parentId": 13, "pos": [250, 450], "linkIds": [11, 12] }, + { "id": 10, "pos": [250, 650], "linkIds": [9, 10, 11, 12] }, + { "id": 11, "parentId": 10, "pos": [300, 650], "linkIds": [9] }, + { "id": 12, "parentId": 11, "pos": [350, 650], "linkIds": [9] }, + { "id": 13, "parentId": 10, "pos": [250, 570], "linkIds": [10, 11, 12] }, + { "id": 14, "parentId": 13, "pos": [300, 570], "linkIds": [10] }, + { "id": 15, "parentId": 14, "pos": [350, 570], "linkIds": [10] } + ] + }, + "version": 0.4 +} diff --git a/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts b/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts index 65b468940..4b1d29cca 100644 --- a/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts @@ -17,6 +17,10 @@ import { LinkDirection } from '@/lib/litegraph/src/litegraph' import type { ConnectingLink } from '@/lib/litegraph/src/interfaces' +import { + createMockNodeInputSlot, + createMockNodeOutputSlot +} from '@/utils/__tests__/litegraphTestUtils' interface TestContext { network: LinkNetwork & { add(node: LGraphNode): void } @@ -136,7 +140,7 @@ describe('LinkConnector', () => { connector.state.connectingTo = 'input' expect(() => { - connector.moveInputLink(network, { link: 1 } as any) + connector.moveInputLink(network, createMockNodeInputSlot({ link: 1 })) }).toThrow('Already dragging links.') }) }) @@ -174,7 +178,10 @@ describe('LinkConnector', () => { connector.state.connectingTo = 'output' expect(() => { - connector.moveOutputLink(network, { links: [1] } as any) + connector.moveOutputLink( + network, + createMockNodeOutputSlot({ links: [1] }) + ) }).toThrow('Already dragging links.') }) }) diff --git a/src/lib/litegraph/src/canvas/LinkConnector.integration.test.ts b/src/lib/litegraph/src/canvas/LinkConnector.integration.test.ts index 3c4368c5a..671709345 100644 --- a/src/lib/litegraph/src/canvas/LinkConnector.integration.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnector.integration.test.ts @@ -12,6 +12,10 @@ import { LGraphNode, LLink, LinkConnector } from '@/lib/litegraph/src/litegraph' import { test as baseTest } from '../__fixtures__/testExtensions' import type { ConnectingLink } from '@/lib/litegraph/src/interfaces' +import { + createMockCanvasPointerEvent, + createMockCanvasRenderingContext2D +} from '@/utils/__tests__/litegraphTestUtils' interface TestContext { graph: LGraph @@ -35,9 +39,9 @@ const test = baseTest.extend({ }, graph: async ({ reroutesComplexGraph }, use) => { - const ctx = vi.fn(() => ({ measureText: vi.fn(() => ({ width: 10 })) })) + const mockCtx = createMockCanvasRenderingContext2D() for (const node of reroutesComplexGraph.nodes) { - node.updateArea(ctx() as unknown as CanvasRenderingContext2D) + node.updateArea(mockCtx) } await use(reroutesComplexGraph) }, @@ -186,10 +190,10 @@ const test = baseTest.extend({ }) function mockedNodeTitleDropEvent(node: LGraphNode): CanvasPointerEvent { - return { - canvasX: node.pos[0] + node.size[0] / 2, - canvasY: node.pos[1] + 16 - } as any + return createMockCanvasPointerEvent( + node.pos[0] + node.size[0] / 2, + node.pos[1] + 16 + ) } function mockedInputDropEvent( @@ -197,10 +201,7 @@ function mockedInputDropEvent( slot: number ): CanvasPointerEvent { const pos = node.getInputPos(slot) - return { - canvasX: pos[0], - canvasY: pos[1] - } as any + return createMockCanvasPointerEvent(pos[0], pos[1]) } function mockedOutputDropEvent( @@ -208,10 +209,7 @@ function mockedOutputDropEvent( slot: number ): CanvasPointerEvent { const pos = node.getOutputPos(slot) - return { - canvasX: pos[0], - canvasY: pos[1] - } as any + return createMockCanvasPointerEvent(pos[0], pos[1]) } describe('LinkConnector Integration', () => { @@ -239,7 +237,7 @@ describe('LinkConnector Integration', () => { const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 - const dropEvent = { canvasX, canvasY } as any + const dropEvent = createMockCanvasPointerEvent(canvasX, canvasY) // Drop links, ensure reset has not been run connector.dropLinks(graph, dropEvent) @@ -281,7 +279,7 @@ describe('LinkConnector Integration', () => { const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 - const dropEvent = { canvasX, canvasY } as any + const dropEvent = createMockCanvasPointerEvent(canvasX, canvasY) connector.dropLinks(graph, dropEvent) connector.reset() @@ -422,7 +420,7 @@ describe('LinkConnector Integration', () => { const canvasX = disconnectedNode.pos[0] + disconnectedNode.size[0] / 2 const canvasY = disconnectedNode.pos[1] + 16 - const dropEvent = { canvasX, canvasY } as any + const dropEvent = createMockCanvasPointerEvent(canvasX, canvasY) connector.dropLinks(graph, dropEvent) connector.reset() @@ -473,9 +471,10 @@ describe('LinkConnector Integration', () => { expect(floatingLink).toBeInstanceOf(LLink) const floatingReroute = LLink.getReroutes(graph, floatingLink)[0] - const canvasX = floatingReroute.pos[0] - const canvasY = floatingReroute.pos[1] - const dropEvent = { canvasX, canvasY } as any + const dropEvent = createMockCanvasPointerEvent( + floatingReroute.pos[0], + floatingReroute.pos[1] + ) connector.dropLinks(graph, dropEvent) connector.reset() @@ -554,7 +553,10 @@ describe('LinkConnector Integration', () => { const manyOutputsNode = graph.getNodeById(4)! const canvasX = floatingReroute.pos[0] const canvasY = floatingReroute.pos[1] - const floatingRerouteEvent = { canvasX, canvasY } as any + const floatingRerouteEvent = createMockCanvasPointerEvent( + canvasX, + canvasY + ) connector.moveOutputLink(graph, manyOutputsNode.outputs[0]) connector.dropLinks(graph, floatingRerouteEvent) @@ -579,7 +581,7 @@ describe('LinkConnector Integration', () => { const canvasX = reroute7.pos[0] const canvasY = reroute7.pos[1] - const reroute7Event = { canvasX, canvasY } as any + const reroute7Event = createMockCanvasPointerEvent(canvasX, canvasY) const toSortedRerouteChain = (linkIds: number[]) => linkIds @@ -698,7 +700,7 @@ describe('LinkConnector Integration', () => { const canvasY = disconnectedNode.pos[1] connector.dragFromReroute(graph, floatingReroute) - connector.dropLinks(graph, { canvasX, canvasY } as any) + connector.dropLinks(graph, createMockCanvasPointerEvent(canvasX, canvasY)) connector.reset() expect(graph.floatingLinks.size).toBe(0) @@ -716,7 +718,7 @@ describe('LinkConnector Integration', () => { const canvasY = reroute8.pos[1] connector.dragFromReroute(graph, floatingReroute) - connector.dropLinks(graph, { canvasX, canvasY } as any) + connector.dropLinks(graph, createMockCanvasPointerEvent(canvasX, canvasY)) connector.reset() expect(graph.floatingLinks.size).toBe(0) @@ -801,10 +803,10 @@ describe('LinkConnector Integration', () => { connector.moveOutputLink(graph, floatingOutNode.outputs[0]) const manyOutputsNode = graph.getNodeById(4)! - const dropEvent = { - canvasX: manyOutputsNode.pos[0], - canvasY: manyOutputsNode.pos[1] - } as any + const dropEvent = createMockCanvasPointerEvent( + manyOutputsNode.pos[0], + manyOutputsNode.pos[1] + ) connector.dropLinks(graph, dropEvent) connector.reset() @@ -818,9 +820,11 @@ describe('LinkConnector Integration', () => { connector.moveOutputLink(graph, manyOutputsNode.outputs[0]) const disconnectedNode = graph.getNodeById(9)! - dropEvent.canvasX = disconnectedNode.pos[0] - dropEvent.canvasY = disconnectedNode.pos[1] - connector.dropLinks(graph, dropEvent) + const dropEvent2 = createMockCanvasPointerEvent( + disconnectedNode.pos[0], + disconnectedNode.pos[1] + ) + connector.dropLinks(graph, dropEvent2) connector.reset() const newOutput = disconnectedNode.outputs[0] @@ -951,10 +955,10 @@ describe('LinkConnector Integration', () => { const targetReroute = graph.reroutes.get(targetRerouteId)! const nextLinkIds = getNextLinkIds(targetReroute.linkIds) - const dropEvent = { - canvasX: targetReroute.pos[0], - canvasY: targetReroute.pos[1] - } as any + const dropEvent = createMockCanvasPointerEvent( + targetReroute.pos[0], + targetReroute.pos[1] + ) connector.dragNewFromOutput( graph, @@ -1094,10 +1098,10 @@ describe('LinkConnector Integration', () => { connector.dragFromReroute(graph, fromReroute) - const dropEvent = { - canvasX: toReroute.pos[0], - canvasY: toReroute.pos[1] - } as any + const dropEvent = createMockCanvasPointerEvent( + toReroute.pos[0], + toReroute.pos[1] + ) connector.dropLinks(graph, dropEvent) connector.reset() @@ -1167,10 +1171,10 @@ describe('LinkConnector Integration', () => { const fromReroute = graph.reroutes.get(from)! const toReroute = graph.reroutes.get(to)! - const dropEvent = { - canvasX: toReroute.pos[0], - canvasY: toReroute.pos[1] - } as any + const dropEvent = createMockCanvasPointerEvent( + toReroute.pos[0], + toReroute.pos[1] + ) connector.dragFromReroute(graph, fromReroute) connector.dropLinks(graph, dropEvent) @@ -1204,10 +1208,10 @@ describe('LinkConnector Integration', () => { const node = graph.getNodeById(nodeId)! const input = node.inputs[0] const reroute = graph.getReroute(rerouteId)! - const dropEvent = { - canvasX: reroute.pos[0], - canvasY: reroute.pos[1] - } as any + const dropEvent = createMockCanvasPointerEvent( + reroute.pos[0], + reroute.pos[1] + ) connector.dragNewFromInput(graph, node, input) connector.dropLinks(graph, dropEvent) @@ -1234,7 +1238,7 @@ describe('LinkConnector Integration', () => { const node = graph.getNodeById(nodeId)! const reroute = graph.getReroute(rerouteId)! - const dropEvent = { canvasX: node.pos[0], canvasY: node.pos[1] } as any + const dropEvent = createMockCanvasPointerEvent(node.pos[0], node.pos[1]) connector.dragFromReroute(graph, reroute) connector.dropLinks(graph, dropEvent) @@ -1262,10 +1266,10 @@ describe('LinkConnector Integration', () => { const node = graph.getNodeById(nodeId)! const reroute = graph.getReroute(rerouteId)! const inputPos = node.getInputPos(0) - const dropOnInputEvent = { - canvasX: inputPos[0], - canvasY: inputPos[1] - } as any + const dropOnInputEvent = createMockCanvasPointerEvent( + inputPos[0], + inputPos[1] + ) connector.dragFromReroute(graph, reroute) connector.dropLinks(graph, dropOnInputEvent) diff --git a/src/lib/litegraph/src/canvas/LinkConnector.test.ts b/src/lib/litegraph/src/canvas/LinkConnector.test.ts index 29786bdf1..9ec8e3dfb 100644 --- a/src/lib/litegraph/src/canvas/LinkConnector.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnector.test.ts @@ -1,23 +1,46 @@ // TODO: Fix these tests after migration import { beforeEach, describe, expect, test, vi } from 'vitest' -import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' -// We don't strictly need RenderLink interface import for the mock import { LinkConnector } from '@/lib/litegraph/src/litegraph' +import { + createMockCanvasPointerEvent, + createMockLGraphNode, + createMockLinkNetwork, + createMockNodeInputSlot, + createMockNodeOutputSlot +} from '@/utils/__tests__/litegraphTestUtils' // Mocks const mockSetConnectingLinks = vi.fn() +type RenderLinkItem = LinkConnector['renderLinks'][number] + // Mock a structure that has the needed method -function mockRenderLinkImpl(canConnect: boolean) { - return { - canConnectToInput: vi.fn().mockReturnValue(canConnect) - // Add other properties if they become necessary for tests +function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem { + const partial: Partial = { + toType: 'output', + fromPos: [0, 0], + fromSlotIndex: 0, + fromDirection: 0, + network: createMockLinkNetwork(), + node: createMockLGraphNode(), + fromSlot: createMockNodeOutputSlot(), + dragDirection: 0, + canConnectToInput: vi.fn().mockReturnValue(canConnect), + canConnectToOutput: vi.fn().mockReturnValue(false), + canConnectToReroute: vi.fn().mockReturnValue(false), + connectToInput: vi.fn(), + connectToOutput: vi.fn(), + connectToSubgraphInput: vi.fn(), + connectToRerouteOutput: vi.fn(), + connectToSubgraphOutput: vi.fn(), + connectToRerouteInput: vi.fn() } + return partial as RenderLinkItem } -const mockNode = {} as LGraphNode -const mockInput = {} as INodeInputSlot +const mockNode = createMockLGraphNode() +const mockInput = createMockNodeInputSlot() describe.skip('LinkConnector', () => { let connector: LinkConnector @@ -37,8 +60,7 @@ describe.skip('LinkConnector', () => { test('should return true if at least one render link can connect', () => { const link1 = mockRenderLinkImpl(false) const link2 = mockRenderLinkImpl(true) - // Cast to any to satisfy the push requirement, as we only need the canConnectToInput method - connector.renderLinks.push(link1 as any, link2 as any) + connector.renderLinks.push(link1, link2) expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true) expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) @@ -47,7 +69,7 @@ describe.skip('LinkConnector', () => { test('should return false if no render links can connect', () => { const link1 = mockRenderLinkImpl(false) const link2 = mockRenderLinkImpl(false) - connector.renderLinks.push(link1 as any, link2 as any) + connector.renderLinks.push(link1, link2) expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false) expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) @@ -57,7 +79,7 @@ describe.skip('LinkConnector', () => { const link1 = mockRenderLinkImpl(false) const link2 = mockRenderLinkImpl(true) // This one can connect const link3 = mockRenderLinkImpl(false) - connector.renderLinks.push(link1 as any, link2 as any, link3 as any) + connector.renderLinks.push(link1, link2, link3) expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true) @@ -88,7 +110,10 @@ describe.skip('LinkConnector', () => { test('should call the listener when the event is dispatched before reset', () => { const listener = vi.fn() - const eventData = { renderLinks: [], event: {} as any } // Mock event data + const eventData = { + renderLinks: [], + event: createMockCanvasPointerEvent(0, 0) + } connector.listenUntilReset('before-drop-links', listener) connector.events.dispatch('before-drop-links', eventData) @@ -120,7 +145,10 @@ describe.skip('LinkConnector', () => { test('should not call the listener after reset is dispatched', () => { const listener = vi.fn() - const eventData = { renderLinks: [], event: {} as any } + const eventData = { + renderLinks: [], + event: createMockCanvasPointerEvent(0, 0) + } connector.listenUntilReset('before-drop-links', listener) // Dispatch reset first diff --git a/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts b/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts index 3d9740c4f..8c2922005 100644 --- a/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts @@ -9,10 +9,20 @@ import { LLink } from '@/lib/litegraph/src/litegraph' import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink' -import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph' +import type { + CanvasPointerEvent, + NodeInputSlot +} from '@/lib/litegraph/src/litegraph' import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' import { createTestSubgraph } from '../subgraph/__fixtures__/subgraphHelpers' +import { + createMockCanvasPointerEvent, + createMockNodeInputSlot +} from '@/utils/__tests__/litegraphTestUtils' + +type MockPointerEvent = CanvasPointerEvent +type MockRenderLink = ToOutputRenderLink describe('LinkConnector SubgraphInput connection validation', () => { let connector: LinkConnector @@ -206,10 +216,7 @@ describe('LinkConnector SubgraphInput connection validation', () => { connector.state.connectingTo = 'output' // Create mock event - const mockEvent = { - canvasX: 100, - canvasY: 100 - } as any + const mockEvent: MockPointerEvent = createMockCanvasPointerEvent(100, 100) // Mock the getSlotInPosition to return the subgraph input const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0]) @@ -256,10 +263,7 @@ describe('LinkConnector SubgraphInput connection validation', () => { connector.state.connectingTo = 'output' // Create mock event - const mockEvent = { - canvasX: 100, - canvasY: 100 - } as any + const mockEvent: MockPointerEvent = createMockCanvasPointerEvent(100, 100) // Mock the getSlotInPosition to return the subgraph input const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0]) @@ -342,12 +346,12 @@ describe('LinkConnector SubgraphInput connection validation', () => { }) // Create a mock render link without the method - const mockLink = { - fromSlot: { type: 'number' } + const mockLink: Partial = { + fromSlot: createMockNodeInputSlot({ type: 'number' }) // No canConnectToSubgraphInput method - } as any + } - connector.renderLinks.push(mockLink) + connector.renderLinks.push(mockLink as MockRenderLink) const subgraphInput = subgraph.inputs[0] diff --git a/src/lib/litegraph/src/canvas/ToOutputRenderLink.test.ts b/src/lib/litegraph/src/canvas/ToOutputRenderLink.test.ts index 73bfec657..7c9706ee6 100644 --- a/src/lib/litegraph/src/canvas/ToOutputRenderLink.test.ts +++ b/src/lib/litegraph/src/canvas/ToOutputRenderLink.test.ts @@ -4,23 +4,30 @@ import { LinkDirection, ToOutputRenderLink } from '@/lib/litegraph/src/litegraph' +import type { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget' +import type { LinkConnectorEventMap } from '@/lib/litegraph/src/infrastructure/LinkConnectorEventMap' +import { + createMockLGraphNode, + createMockLinkNetwork, + createMockNodeInputSlot, + createMockNodeOutputSlot +} from '@/utils/__tests__/litegraphTestUtils' describe('ToOutputRenderLink', () => { describe('connectToOutput', () => { it('should return early if inputNode is null', () => { // Setup - const mockNetwork = {} - const mockFromSlot = {} - const mockNode = { - id: 'test-id', + const mockNetwork = createMockLinkNetwork() + const mockFromSlot = createMockNodeInputSlot() + const mockNode = createMockLGraphNode({ inputs: [mockFromSlot], getInputPos: vi.fn().mockReturnValue([0, 0]) - } + }) const renderLink = new ToOutputRenderLink( - mockNetwork as any, - mockNode as any, - mockFromSlot as any, + mockNetwork, + mockNode, + mockFromSlot, undefined, LinkDirection.CENTER ) @@ -30,18 +37,21 @@ describe('ToOutputRenderLink', () => { value: null }) - const mockTargetNode = { + const mockTargetNode = createMockLGraphNode({ connectSlots: vi.fn() - } - const mockEvents = { + }) + const mockEvents: Partial> = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), dispatch: vi.fn() } // Act renderLink.connectToOutput( - mockTargetNode as any, - {} as any, - mockEvents as any + mockTargetNode, + createMockNodeOutputSlot(), + mockEvents as CustomEventTarget ) // Assert @@ -51,35 +61,37 @@ describe('ToOutputRenderLink', () => { it('should create connection and dispatch event when inputNode exists', () => { // Setup - const mockNetwork = {} - const mockFromSlot = {} - const mockNode = { - id: 'test-id', + const mockNetwork = createMockLinkNetwork() + const mockFromSlot = createMockNodeInputSlot() + const mockNode = createMockLGraphNode({ inputs: [mockFromSlot], getInputPos: vi.fn().mockReturnValue([0, 0]) - } + }) const renderLink = new ToOutputRenderLink( - mockNetwork as any, - mockNode as any, - mockFromSlot as any, + mockNetwork, + mockNode, + mockFromSlot, undefined, LinkDirection.CENTER ) const mockNewLink = { id: 'new-link' } - const mockTargetNode = { + const mockTargetNode = createMockLGraphNode({ connectSlots: vi.fn().mockReturnValue(mockNewLink) - } - const mockEvents = { + }) + const mockEvents: Partial> = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), dispatch: vi.fn() } // Act renderLink.connectToOutput( - mockTargetNode as any, - {} as any, - mockEvents as any + mockTargetNode, + createMockNodeOutputSlot(), + mockEvents as CustomEventTarget ) // Assert diff --git a/src/lib/litegraph/src/contextMenuCompat.test.ts b/src/lib/litegraph/src/contextMenuCompat.test.ts index fc4c7d156..246551dc9 100644 --- a/src/lib/litegraph/src/contextMenuCompat.test.ts +++ b/src/lib/litegraph/src/contextMenuCompat.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph' import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' +import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils' describe('contextMenuCompat', () => { let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions @@ -13,11 +14,11 @@ describe('contextMenuCompat', () => { originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions // Create mock canvas - mockCanvas = { + mockCanvas = createMockCanvas({ constructor: { prototype: LGraphCanvas.prototype - } - } as unknown as LGraphCanvas + } as typeof LGraphCanvas + } as Partial) // Clear console warnings vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -54,11 +55,12 @@ describe('contextMenuCompat', () => { // Simulate extension monkey-patching const original = LGraphCanvas.prototype.getCanvasMenuOptions - LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { - const items = (original as any).apply(this, args) - items.push({ content: 'Custom Item', callback: () => {} }) - return items - } + LGraphCanvas.prototype.getCanvasMenuOptions = + function (): (IContextMenuValue | null)[] { + const items = original.call(this) + items.push({ content: 'Custom Item', callback: () => {} }) + return items + } // Should have logged a warning with extension name expect(warnSpy).toHaveBeenCalledWith( @@ -83,8 +85,10 @@ describe('contextMenuCompat', () => { legacyMenuCompat.install(LGraphCanvas.prototype, methodName) legacyMenuCompat.setCurrentExtension('test.extension') - const patchFunction = function (this: LGraphCanvas, ...args: any[]) { - const items = (originalGetCanvasMenuOptions as any).apply(this, args) + const patchFunction = function ( + this: LGraphCanvas + ): (IContextMenuValue | null)[] { + const items = originalGetCanvasMenuOptions.call(this) items.push({ content: 'Custom', callback: () => {} }) return items } diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index aad3dff3f..9df7339af 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -254,7 +254,10 @@ type KeysOfType = Exclude< > /** The names of all (optional) methods and functions in T */ -export type MethodNames = KeysOfType any) | undefined> +export type MethodNames = KeysOfType< + T, + ((...args: unknown[]) => unknown) | undefined +> export interface NewNodePosition { node: LGraphNode newPos: { @@ -459,28 +462,6 @@ export interface ISubgraphInput extends INodeInputSlot { _subgraphSlot: SubgraphInput } -/** - * Shorthand for {@link Parameters} of optional callbacks. - * @example - * ```ts - * const { onClick } = CustomClass.prototype - * CustomClass.prototype.onClick = function (...args: CallbackParams) { - * const r = onClick?.apply(this, args) - * // ... - * return r - * } - * ``` - */ -export type CallbackParams any) | undefined> = - Parameters> - -/** - * Shorthand for {@link ReturnType} of optional callbacks. - * @see {@link CallbackParams} - */ -export type CallbackReturn any) | undefined> = - ReturnType> - /** * An object that can be hovered over. */ diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 0b24eb47b..59d62a84f 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -104,6 +104,8 @@ export type { } from './interfaces' export { LGraph, + type GroupNodeConfigEntry, + type GroupNodeWorkflowData, type LGraphTriggerAction, type LGraphTriggerParam } from './LGraph' diff --git a/src/lib/litegraph/src/node/NodeSlot.ts b/src/lib/litegraph/src/node/NodeSlot.ts index 4e17069b2..9fac7816f 100644 --- a/src/lib/litegraph/src/node/NodeSlot.ts +++ b/src/lib/litegraph/src/node/NodeSlot.ts @@ -30,7 +30,7 @@ export interface IDrawOptions { highlight?: boolean } -const ROTATION_OFFSET = -Math.PI / 2 +const ROTATION_OFFSET = -Math.PI /** Shared base class for {@link LGraphNode} input and output slots. */ export abstract class NodeSlot extends SlotBase implements INodeSlot { diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts index 9e9454a81..366dbb96d 100644 --- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts +++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts @@ -4,11 +4,7 @@ import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLink import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError' import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError' import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError' -import type { - CallbackParams, - CallbackReturn, - ISlotType -} from '@/lib/litegraph/src/interfaces' +import type { ISlotType } from '@/lib/litegraph/src/interfaces' import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph' import type { Subgraph } from './Subgraph' @@ -45,8 +41,8 @@ type ResolvedInput = { */ export class ExecutableNodeDTO implements ExecutableLGraphNode { applyToGraph?( - ...args: CallbackParams - ): CallbackReturn + ...args: Parameters> + ): ReturnType> /** The graph that this node is a part of. */ readonly graph: LGraph | Subgraph diff --git a/src/lib/litegraph/src/subgraph/SubgraphEdgeCases.test.ts b/src/lib/litegraph/src/subgraph/SubgraphEdgeCases.test.ts index b161467e2..5a5ba1745 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphEdgeCases.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphEdgeCases.test.ts @@ -83,7 +83,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => { name: 'fake', type: 'number', disconnect: () => {} - } as any + } as Partial[0]> as Parameters< + typeof subgraph.removeInput + >[0] // Should throw appropriate error for non-existent input expect(() => { @@ -97,41 +99,43 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => { name: 'fake', type: 'number', disconnect: () => {} - } as any + } as Partial[0]> as Parameters< + typeof subgraph.removeOutput + >[0] expect(() => { subgraph.removeOutput(fakeOutput) }).toThrow(/Output not found/) // Expected error }) - it('should handle null/undefined input names', () => { + it('should throw error for null/undefined input names', () => { const subgraph = createTestSubgraph() - // ISSUE: Current implementation allows null/undefined names which may cause runtime errors - // TODO: Consider adding validation to prevent null/undefined names - // This test documents the current permissive behavior - expect(() => { - subgraph.addInput(null as any, 'number') - }).not.toThrow() // Current behavior: allows null + const nullString: string = null! + const undefinedString: string = undefined! expect(() => { - subgraph.addInput(undefined as any, 'number') - }).not.toThrow() // Current behavior: allows undefined + subgraph.addInput(nullString, 'number') + }).toThrow() + + expect(() => { + subgraph.addInput(undefinedString, 'number') + }).toThrow() }) it('should handle null/undefined output names', () => { const subgraph = createTestSubgraph() - // ISSUE: Current implementation allows null/undefined names which may cause runtime errors - // TODO: Consider adding validation to prevent null/undefined names - // This test documents the current permissive behavior - expect(() => { - subgraph.addOutput(null as any, 'number') - }).not.toThrow() // Current behavior: allows null + const nullString: string = null! + const undefinedString: string = undefined! expect(() => { - subgraph.addOutput(undefined as any, 'number') - }).not.toThrow() // Current behavior: allows undefined + subgraph.addOutput(nullString, 'number') + }).toThrow() + + expect(() => { + subgraph.addOutput(undefinedString, 'number') + }).toThrow() }) it('should handle empty string names', () => { @@ -151,14 +155,16 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => { it('should handle undefined types gracefully', () => { const subgraph = createTestSubgraph() - // Undefined type should not crash but may have default behavior + const undefinedString: string = undefined! + + // Undefined type should throw error expect(() => { - subgraph.addInput('test', undefined as any) - }).not.toThrow() + subgraph.addInput('test', undefinedString) + }).toThrow() expect(() => { - subgraph.addOutput('test', undefined as any) - }).not.toThrow() + subgraph.addOutput('test', undefinedString) + }).toThrow() }) it('should handle duplicate slot names', () => { diff --git a/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts b/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts index 7b936392a..92699283e 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts @@ -10,6 +10,12 @@ import { createTestSubgraphNode } from './__fixtures__/subgraphHelpers' +type InputWithWidget = { + _widget?: IWidget | { type: string; value: unknown; name: string } + _connection?: { id: number; type: string } + _listenerController?: AbortController +} + describe.skip('SubgraphNode Memory Management', () => { describe.skip('Event Listener Cleanup', () => { it('should register event listeners on construction', () => { @@ -308,14 +314,14 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => { // Set widget reference if (input && '_widget' in input) { - ;(input as any)._widget = mockWidget - expect((input as any)._widget).toBe(mockWidget) + ;(input as InputWithWidget)._widget = mockWidget + expect((input as InputWithWidget)._widget).toBe(mockWidget) } // Clear widget reference if (input && '_widget' in input) { - ;(input as any)._widget = undefined - expect((input as any)._widget).toBeUndefined() + ;(input as InputWithWidget)._widget = undefined + expect((input as InputWithWidget)._widget).toBeUndefined() } } ) @@ -360,30 +366,34 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => { // Set up references that should be cleaned up const mockReferences = { - widget: { type: 'number', value: 42 }, + widget: { type: 'number', value: 42, name: 'mock_widget' }, connection: { id: 1, type: 'number' }, listener: vi.fn() } // Set references if (input) { - ;(input as any)._widget = mockReferences.widget - ;(input as any)._connection = mockReferences.connection + ;(input as InputWithWidget)._widget = mockReferences.widget + ;(input as InputWithWidget)._connection = mockReferences.connection } if (output) { - ;(input as any)._connection = mockReferences.connection + ;(input as InputWithWidget)._connection = mockReferences.connection } // Verify references are set - expect((input as any)?._widget).toBe(mockReferences.widget) - expect((input as any)?._connection).toBe(mockReferences.connection) + expect((input as InputWithWidget)?._widget).toBe(mockReferences.widget) + expect((input as InputWithWidget)?._connection).toBe( + mockReferences.connection + ) // Simulate proper cleanup (what onRemoved should do) subgraphNode.onRemoved() // Input-specific listeners should be cleaned up (this works) if (input && '_listenerController' in input) { - expect((input as any)._listenerController?.signal.aborted).toBe(true) + expect( + (input as InputWithWidget)._listenerController?.signal.aborted + ).toBe(true) } } ) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts index 96e2e5dd3..10493cfd4 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' +import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import { subgraphTest } from './__fixtures__/subgraphFixtures' import { @@ -531,7 +532,7 @@ describe.skip('SubgraphNode Cleanup', () => { // Now trigger an event - only node1 should respond subgraph.events.dispatch('input-added', { - input: { name: 'test', type: 'number', id: 'test-id' } as any + input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput }) // Only node1 should have added an input @@ -558,7 +559,7 @@ describe.skip('SubgraphNode Cleanup', () => { // Trigger an event - no nodes should respond subgraph.events.dispatch('input-added', { - input: { name: 'test', type: 'number', id: 'test-id' } as any + input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput }) // Without cleanup: all 3 removed nodes would have added an input diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts index c76e9d5ee..feb6b1d50 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts @@ -3,12 +3,18 @@ import { describe, expect, it, vi } from 'vitest' import { LGraphButton } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' +import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import { createTestSubgraph, createTestSubgraphNode } from './__fixtures__/subgraphHelpers' +interface MockPointerEvent { + canvasX: number + canvasY: number +} + describe.skip('SubgraphNode Title Button', () => { describe.skip('Constructor', () => { it('should automatically add enter_subgraph button', () => { @@ -58,7 +64,7 @@ describe.skip('SubgraphNode Title Button', () => { const canvas = { openSubgraph: vi.fn(), dispatch: vi.fn() - } as unknown as LGraphCanvas + } as Partial as LGraphCanvas subgraphNode.onTitleButtonClick(enterButton, canvas) @@ -78,7 +84,7 @@ describe.skip('SubgraphNode Title Button', () => { const canvas = { openSubgraph: vi.fn(), dispatch: vi.fn() - } as unknown as LGraphCanvas + } as Partial as LGraphCanvas subgraphNode.onTitleButtonClick(customButton, canvas) @@ -119,16 +125,16 @@ describe.skip('SubgraphNode Title Button', () => { const canvas = { ctx: { measureText: vi.fn().mockReturnValue({ width: 25 }) - } as unknown as CanvasRenderingContext2D, + } as Partial as CanvasRenderingContext2D, openSubgraph: vi.fn(), dispatch: vi.fn() - } as unknown as LGraphCanvas + } as Partial as LGraphCanvas // Simulate click on the enter button - const event = { + const event: MockPointerEvent = { canvasX: 275, // Near right edge where button should be canvasY: 80 // In title area - } as any + } // Calculate node-relative position const clickPosRelativeToNode: [number, number] = [ @@ -138,7 +144,7 @@ describe.skip('SubgraphNode Title Button', () => { // @ts-expect-error onMouseDown possibly undefined const handled = subgraphNode.onMouseDown( - event, + event as Partial as CanvasPointerEvent, clickPosRelativeToNode, canvas ) @@ -156,16 +162,16 @@ describe.skip('SubgraphNode Title Button', () => { const canvas = { ctx: { measureText: vi.fn().mockReturnValue({ width: 25 }) - } as unknown as CanvasRenderingContext2D, + } as Partial as CanvasRenderingContext2D, openSubgraph: vi.fn(), dispatch: vi.fn() - } as unknown as LGraphCanvas + } as Partial as LGraphCanvas // Click in the body of the node, not on button - const event = { + const event: MockPointerEvent = { canvasX: 200, // Middle of node canvasY: 150 // Body area - } as any + } // Calculate node-relative position const clickPosRelativeToNode: [number, number] = [ @@ -173,9 +179,8 @@ describe.skip('SubgraphNode Title Button', () => { 150 - subgraphNode.pos[1] // 150 - 100 = 50 ] - // @ts-expect-error onMouseDown possibly undefined - const handled = subgraphNode.onMouseDown( - event, + const handled = subgraphNode.onMouseDown!( + event as Partial as CanvasPointerEvent, clickPosRelativeToNode, canvas ) @@ -204,25 +209,24 @@ describe.skip('SubgraphNode Title Button', () => { const canvas = { ctx: { measureText: vi.fn().mockReturnValue({ width: 25 }) - } as unknown as CanvasRenderingContext2D, + } as Partial as CanvasRenderingContext2D, openSubgraph: vi.fn(), dispatch: vi.fn() - } as unknown as LGraphCanvas + } as Partial as LGraphCanvas // Try to click on where the button would be - const event = { + const event: MockPointerEvent = { canvasX: 275, canvasY: 80 - } as any + } const clickPosRelativeToNode: [number, number] = [ 275 - subgraphNode.pos[0], // 175 80 - subgraphNode.pos[1] // -20 ] - // @ts-expect-error onMouseDown possibly undefined - const handled = subgraphNode.onMouseDown( - event, + const handled = subgraphNode.onMouseDown!( + event as Partial as CanvasPointerEvent, clickPosRelativeToNode, canvas ) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index e26e1cd48..a1fc265d8 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -28,8 +28,8 @@ import type { } from '@/lib/litegraph/src/types/serialisation' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget' import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget' -import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap' import { ExecutableNodeDTO } from './ExecutableNodeDTO' import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO' @@ -331,9 +331,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { inputWidget: IWidgetLocator | undefined ) { // Use the first matching widget - const promotedWidget = toConcreteWidget(widget, this).createCopyForNode( - this - ) + const promotedWidget = + widget instanceof BaseWidget + ? widget.createCopyForNode(this) + : { ...widget, node: this } if (widget instanceof AssetWidget) promotedWidget.options.nodeType ??= widget.node.type diff --git a/src/lib/litegraph/src/subgraph/SubgraphSlotVisualFeedback.test.ts b/src/lib/litegraph/src/subgraph/SubgraphSlotVisualFeedback.test.ts index baf842812..9a85ddb18 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphSlotVisualFeedback.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphSlotVisualFeedback.test.ts @@ -1,13 +1,21 @@ // TODO: Fix these tests after migration import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces' import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph } from './__fixtures__/subgraphHelpers' +interface MockColorContext { + defaultInputColor: string + defaultOutputColor: string + getConnectedColor: ReturnType + getDisconnectedColor: ReturnType +} + describe.skip('SubgraphSlot visual feedback', () => { let mockCtx: CanvasRenderingContext2D - let mockColorContext: any + let mockColorContext: MockColorContext let globalAlphaValues: number[] beforeEach(() => { @@ -34,7 +42,8 @@ describe.skip('SubgraphSlot visual feedback', () => { rect: vi.fn(), fillText: vi.fn() } - mockCtx = mockContext as unknown as CanvasRenderingContext2D + mockCtx = + mockContext as Partial as CanvasRenderingContext2D // Create a mock color context mockColorContext = { @@ -42,7 +51,7 @@ describe.skip('SubgraphSlot visual feedback', () => { defaultOutputColor: '#00FF00', getConnectedColor: vi.fn().mockReturnValue('#0000FF'), getDisconnectedColor: vi.fn().mockReturnValue('#AAAAAA') - } + } as Partial as MockColorContext }) it('should render SubgraphInput slots with full opacity when dragging from compatible slot', () => { @@ -60,7 +69,8 @@ describe.skip('SubgraphSlot visual feedback', () => { // Draw the slot with a compatible fromSlot subgraphInput.draw({ ctx: mockCtx, - colorContext: mockColorContext, + colorContext: + mockColorContext as Partial as DefaultConnectionColors, fromSlot: nodeInput, editorAlpha: 1 }) @@ -80,7 +90,8 @@ describe.skip('SubgraphSlot visual feedback', () => { // Draw subgraphInput2 while dragging from subgraphInput1 (incompatible - both are outputs inside subgraph) subgraphInput2.draw({ ctx: mockCtx, - colorContext: mockColorContext, + colorContext: + mockColorContext as Partial as DefaultConnectionColors, fromSlot: subgraphInput1, editorAlpha: 1 }) @@ -105,7 +116,8 @@ describe.skip('SubgraphSlot visual feedback', () => { // Draw the slot with a compatible fromSlot subgraphOutput.draw({ ctx: mockCtx, - colorContext: mockColorContext, + colorContext: + mockColorContext as Partial as DefaultConnectionColors, fromSlot: nodeOutput, editorAlpha: 1 }) @@ -125,7 +137,8 @@ describe.skip('SubgraphSlot visual feedback', () => { // Draw subgraphOutput2 while dragging from subgraphOutput1 (incompatible - both are inputs inside subgraph) subgraphOutput2.draw({ ctx: mockCtx, - colorContext: mockColorContext, + colorContext: + mockColorContext as Partial as DefaultConnectionColors, fromSlot: subgraphOutput1, editorAlpha: 1 }) @@ -170,7 +183,8 @@ describe.skip('SubgraphSlot visual feedback', () => { // Draw the SubgraphOutput slot while dragging from a node output with incompatible type subgraphOutput.draw({ ctx: mockCtx, - colorContext: mockColorContext, + colorContext: + mockColorContext as Partial as DefaultConnectionColors, fromSlot: nodeStringOutput, editorAlpha: 1 }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index d47ca186d..1d609fe01 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -18,7 +18,7 @@ import { function createNodeWithWidget( title: string, widgetType: TWidgetType = 'number', - widgetValue: any = 42, + widgetValue: unknown = 42, slotType: ISlotType = 'number', tooltip?: string ) { diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/README.md b/src/lib/litegraph/src/subgraph/__fixtures__/README.md index 86d2e9e19..27b983e6a 100644 --- a/src/lib/litegraph/src/subgraph/__fixtures__/README.md +++ b/src/lib/litegraph/src/subgraph/__fixtures__/README.md @@ -5,6 +5,7 @@ This directory contains the testing infrastructure for LiteGraph's subgraph func ## What is a Subgraph? A subgraph in LiteGraph is a graph-within-a-graph that can be reused as a single node. It has: + - Input slots that map to an internal input node - Output slots that map to an internal output node - Internal nodes and connections @@ -14,23 +15,26 @@ A subgraph in LiteGraph is a graph-within-a-graph that can be reused as a single ```typescript // Import what you need -import { createTestSubgraph, assertSubgraphStructure } from "./fixtures/subgraphHelpers" -import { subgraphTest } from "./fixtures/subgraphFixtures" +import { + createTestSubgraph, + assertSubgraphStructure +} from './fixtures/subgraphHelpers' +import { subgraphTest } from './fixtures/subgraphFixtures' // Option 1: Create a subgraph manually -it("should do something", () => { +it('should do something', () => { const subgraph = createTestSubgraph({ - name: "My Test Subgraph", + name: 'My Test Subgraph', inputCount: 2, outputCount: 1 }) - + // Test your functionality expect(subgraph.inputs).toHaveLength(2) }) // Option 2: Use pre-configured fixtures -subgraphTest("should handle events", ({ simpleSubgraph, eventCapture }) => { +subgraphTest('should handle events', ({ simpleSubgraph, eventCapture }) => { // simpleSubgraph comes pre-configured with 1 input, 1 output, and 2 nodes expect(simpleSubgraph.inputs).toHaveLength(1) // Your test logic here @@ -42,16 +46,19 @@ subgraphTest("should handle events", ({ simpleSubgraph, eventCapture }) => { ### `subgraphHelpers.ts` - Core Helper Functions **Main Factory Functions:** + - `createTestSubgraph(options?)` - Creates a fully configured Subgraph instance with root graph - `createTestSubgraphNode(subgraph, options?)` - Creates a SubgraphNode (instance of a subgraph) - `createNestedSubgraphs(options?)` - Creates nested subgraph hierarchies for testing deep structures **Assertion & Validation:** + - `assertSubgraphStructure(subgraph, expected)` - Validates subgraph has expected inputs/outputs/nodes - `verifyEventSequence(events, expectedSequence)` - Ensures events fired in correct order - `logSubgraphStructure(subgraph, label?)` - Debug helper to print subgraph structure **Test Data & Events:** + - `createTestSubgraphData(overrides?)` - Creates raw ExportedSubgraph data for serialization tests - `createComplexSubgraphData(nodeCount?)` - Generates complex subgraph with internal connections - `createEventCapture(eventTarget, eventTypes)` - Sets up event monitoring with automatic cleanup @@ -61,6 +68,7 @@ subgraphTest("should handle events", ({ simpleSubgraph, eventCapture }) => { Pre-configured test scenarios that automatically set up and tear down: **Basic Fixtures (`subgraphTest`):** + - `emptySubgraph` - Minimal subgraph with no inputs/outputs/nodes - `simpleSubgraph` - 1 input ("input": number), 1 output ("output": number), 2 internal nodes - `complexSubgraph` - 3 inputs (data, control, text), 2 outputs (result, status), 5 nodes @@ -69,11 +77,13 @@ Pre-configured test scenarios that automatically set up and tear down: - `eventCapture` - Subgraph with event monitoring for all I/O events **Edge Case Fixtures (`edgeCaseTest`):** + - `circularSubgraph` - Two subgraphs set up for circular reference testing - `deeplyNestedSubgraph` - 50 levels deep for performance/limit testing - `maxIOSubgraph` - 20 inputs and 20 outputs for stress testing ### `testSubgraphs.json` - Sample Test Data + Pre-defined subgraph configurations for consistent testing across different scenarios. **Note on Static UUIDs**: The hardcoded UUIDs in this file (e.g., "simple-subgraph-uuid", "complex-subgraph-uuid") are intentionally static to ensure test reproducibility and snapshot testing compatibility. @@ -83,25 +93,28 @@ Pre-defined subgraph configurations for consistent testing across different scen ### Basic Test Creation ```typescript -import { describe, expect, it } from "vitest" -import { createTestSubgraph, assertSubgraphStructure } from "./fixtures/subgraphHelpers" +import { describe, expect, it } from 'vitest' +import { + createTestSubgraph, + assertSubgraphStructure +} from './fixtures/subgraphHelpers' -describe("My Subgraph Feature", () => { - it("should work correctly", () => { +describe('My Subgraph Feature', () => { + it('should work correctly', () => { const subgraph = createTestSubgraph({ - name: "My Test", + name: 'My Test', inputCount: 2, outputCount: 1, nodeCount: 3 }) - + assertSubgraphStructure(subgraph, { inputCount: 2, outputCount: 1, nodeCount: 3, - name: "My Test" + name: 'My Test' }) - + // Your specific test logic... }) }) @@ -110,13 +123,13 @@ describe("My Subgraph Feature", () => { ### Using Fixtures ```typescript -import { subgraphTest } from "./fixtures/subgraphFixtures" +import { subgraphTest } from './fixtures/subgraphFixtures' -subgraphTest("should handle events", ({ eventCapture }) => { +subgraphTest('should handle events', ({ eventCapture }) => { const { subgraph, capture } = eventCapture - - subgraph.addInput("test", "number") - + + subgraph.addInput('test', 'number') + expect(capture.events).toHaveLength(2) // adding-input, input-added }) ``` @@ -124,16 +137,22 @@ subgraphTest("should handle events", ({ eventCapture }) => { ### Event Testing ```typescript -import { createEventCapture, verifyEventSequence } from "./fixtures/subgraphHelpers" +import { + createEventCapture, + verifyEventSequence +} from './fixtures/subgraphHelpers' -it("should fire events in correct order", () => { +it('should fire events in correct order', () => { const subgraph = createTestSubgraph() - const capture = createEventCapture(subgraph.events, ["adding-input", "input-added"]) - - subgraph.addInput("test", "number") - - verifyEventSequence(capture.events, ["adding-input", "input-added"]) - + const capture = createEventCapture(subgraph.events, [ + 'adding-input', + 'input-added' + ]) + + subgraph.addInput('test', 'number') + + verifyEventSequence(capture.events, ['adding-input', 'input-added']) + capture.cleanup() // Important: clean up listeners }) ``` @@ -141,14 +160,14 @@ it("should fire events in correct order", () => { ### Nested Structure Testing ```typescript -import { createNestedSubgraphs } from "./fixtures/subgraphHelpers" +import { createNestedSubgraphs } from './fixtures/subgraphHelpers' -it("should handle deep nesting", () => { +it('should handle deep nesting', () => { const nested = createNestedSubgraphs({ depth: 5, nodesPerLevel: 2 }) - + expect(nested.subgraphs).toHaveLength(5) expect(nested.leafSubgraph.nodes).toHaveLength(2) }) @@ -159,19 +178,19 @@ it("should handle deep nesting", () => { ### Testing SubgraphNode Instances ```typescript -it("should create and configure a SubgraphNode", () => { +it('should create and configure a SubgraphNode', () => { // First create the subgraph definition const subgraph = createTestSubgraph({ - inputs: [{ name: "value", type: "number" }], - outputs: [{ name: "result", type: "number" }] + inputs: [{ name: 'value', type: 'number' }], + outputs: [{ name: 'result', type: 'number' }] }) - + // Then create an instance of it const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 200], size: [180, 100] }) - + // The SubgraphNode will have matching slots expect(subgraphNode.inputs).toHaveLength(1) expect(subgraphNode.outputs).toHaveLength(1) @@ -182,9 +201,9 @@ it("should create and configure a SubgraphNode", () => { ### Complete Test with Parent Graph ```typescript -subgraphTest("should work in a parent graph", ({ subgraphWithNode }) => { +subgraphTest('should work in a parent graph', ({ subgraphWithNode }) => { const { subgraph, subgraphNode, parentGraph } = subgraphWithNode - + // Everything is pre-configured and connected expect(parentGraph.nodes).toContain(subgraphNode) expect(subgraphNode.graph).toBe(parentGraph) @@ -195,18 +214,21 @@ subgraphTest("should work in a parent graph", ({ subgraphWithNode }) => { ## Configuration Options ### `createTestSubgraph(options)` + ```typescript interface TestSubgraphOptions { - id?: UUID // Custom UUID - name?: string // Custom name - nodeCount?: number // Number of internal nodes - inputCount?: number // Number of inputs (uses generic types) - outputCount?: number // Number of outputs (uses generic types) - inputs?: Array<{ // Specific input definitions + id?: UUID // Custom UUID + name?: string // Custom name + nodeCount?: number // Number of internal nodes + inputCount?: number // Number of inputs (uses generic types) + outputCount?: number // Number of outputs (uses generic types) + inputs?: Array<{ + // Specific input definitions name: string type: ISlotType }> - outputs?: Array<{ // Specific output definitions + outputs?: Array<{ + // Specific output definitions name: string type: ISlotType }> @@ -216,11 +238,12 @@ interface TestSubgraphOptions { **Note**: Cannot specify both `inputs` array and `inputCount` (or `outputs` array and `outputCount`) - the function will throw an error with details. ### `createNestedSubgraphs(options)` + ```typescript interface NestedSubgraphOptions { - depth?: number // Nesting depth (default: 2) - nodesPerLevel?: number // Nodes per subgraph (default: 2) - inputsPerSubgraph?: number // Inputs per subgraph (default: 1) + depth?: number // Nesting depth (default: 2) + nodesPerLevel?: number // Nodes per subgraph (default: 2) + inputsPerSubgraph?: number // Inputs per subgraph (default: 1) outputsPerSubgraph?: number // Outputs per subgraph (default: 1) } ``` @@ -228,11 +251,13 @@ interface NestedSubgraphOptions { ## Important Architecture Notes ### Subgraph vs SubgraphNode + - **Subgraph**: The definition/template (like a class definition) - **SubgraphNode**: An instance of a subgraph placed in a graph (like a class instance) - One Subgraph can have many SubgraphNode instances ### Special Node IDs + - Input node always has ID `-10` (SUBGRAPH_INPUT_ID) - Output node always has ID `-20` (SUBGRAPH_OUTPUT_ID) - These are virtual nodes that exist in every subgraph @@ -240,39 +265,43 @@ interface NestedSubgraphOptions { ### Common Pitfalls 1. **Array vs Index**: The `inputs` and `outputs` arrays don't have an `index` property on items. Use `indexOf()`: + ```typescript // ❌ Wrong expect(input.index).toBe(0) - + // ✅ Correct expect(subgraph.inputs.indexOf(input)).toBe(0) ``` 2. **Graph vs Subgraph Property**: SubgraphInputNode/OutputNode have `subgraph`, not `graph`: + ```typescript // ❌ Wrong expect(inputNode.graph).toBe(subgraph) - + // ✅ Correct expect(inputNode.subgraph).toBe(subgraph) ``` 3. **Event Detail Structure**: Events have specific detail structures: + ```typescript // Input events "adding-input": { name: string, type: string } "input-added": { input: SubgraphInput, index: number } - - // Output events + + // Output events "adding-output": { name: string, type: string } "output-added": { output: SubgraphOutput, index: number } ``` 4. **Links are stored in a Map**: Use `.size` not `.length`: + ```typescript // ❌ Wrong expect(subgraph.links.length).toBe(1) - + // ✅ Correct expect(subgraph.links.size).toBe(1) ``` diff --git a/src/lib/litegraph/src/subgraph/__fixtures__/testSubgraphs.json b/src/lib/litegraph/src/subgraph/__fixtures__/testSubgraphs.json index afce66a3b..35d511a21 100644 --- a/src/lib/litegraph/src/subgraph/__fixtures__/testSubgraphs.json +++ b/src/lib/litegraph/src/subgraph/__fixtures__/testSubgraphs.json @@ -11,9 +11,7 @@ { "name": "a", "type": "number", "link": null }, { "name": "b", "type": "number", "link": null } ], - "outputs": [ - { "name": "result", "type": "number", "links": [] } - ], + "outputs": [{ "name": "result", "type": "number", "links": [] }], "properties": { "operation": "add" }, "flags": {}, "mode": 0 @@ -23,10 +21,10 @@ "groups": [], "config": {}, "definitions": { "subgraphs": [] }, - + "id": "simple-subgraph-uuid", "name": "Simple Math Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -49,7 +47,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [ { "name": "input_a", @@ -57,7 +55,7 @@ "pos": [0, 0] }, { - "name": "input_b", + "name": "input_b", "type": "number", "pos": [0, 1] } @@ -84,9 +82,7 @@ { "name": "a", "type": "number", "link": null }, { "name": "b", "type": "number", "link": null } ], - "outputs": [ - { "name": "result", "type": "number", "links": [1] } - ], + "outputs": [{ "name": "result", "type": "number", "links": [1] }], "properties": {}, "flags": {}, "mode": 0 @@ -100,9 +96,7 @@ { "name": "a", "type": "number", "link": 1 }, { "name": "b", "type": "number", "link": null } ], - "outputs": [ - { "name": "result", "type": "number", "links": [2] } - ], + "outputs": [{ "name": "result", "type": "number", "links": [2] }], "properties": {}, "flags": {}, "mode": 0 @@ -116,9 +110,7 @@ { "name": "a", "type": "number", "link": null }, { "name": "b", "type": "number", "link": null } ], - "outputs": [ - { "name": "result", "type": "boolean", "links": [] } - ], + "outputs": [{ "name": "result", "type": "boolean", "links": [] }], "properties": { "operation": "greater_than" }, "flags": {}, "mode": 0 @@ -132,9 +124,7 @@ { "name": "a", "type": "string", "link": null }, { "name": "b", "type": "string", "link": null } ], - "outputs": [ - { "name": "result", "type": "string", "links": [] } - ], + "outputs": [{ "name": "result", "type": "string", "links": [] }], "properties": {}, "flags": {}, "mode": 0 @@ -161,10 +151,10 @@ "groups": [], "config": {}, "definitions": { "subgraphs": [] }, - + "id": "complex-subgraph-uuid", "name": "Complex Processing Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -187,7 +177,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [ { "name": "number1", @@ -236,7 +226,7 @@ "links": {}, "groups": [], "config": {}, - "definitions": { + "definitions": { "subgraphs": [ { "version": 1, @@ -247,9 +237,7 @@ "pos": [200, 100], "size": [100, 40], "inputs": [], - "outputs": [ - { "name": "value", "type": "number", "links": [] } - ], + "outputs": [{ "name": "value", "type": "number", "links": [] }], "properties": { "value": 42 }, "flags": {}, "mode": 0 @@ -259,10 +247,10 @@ "groups": [], "config": {}, "definitions": { "subgraphs": [] }, - + "id": "nested-level2-uuid", "name": "Level 2 Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -285,7 +273,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [], "outputs": [ { @@ -298,10 +286,10 @@ } ] }, - + "id": "nested-level1-uuid", "name": "Level 1 Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -324,7 +312,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [ { "name": "external_input", @@ -349,10 +337,10 @@ "groups": [], "config": {}, "definitions": { "subgraphs": [] }, - + "id": "empty-subgraph-uuid", "name": "Empty Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -375,7 +363,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [], "outputs": [], "widgets": [] @@ -388,10 +376,10 @@ "groups": [], "config": {}, "definitions": { "subgraphs": [] }, - + "id": "max-io-subgraph-uuid", "name": "Max I/O Subgraph", - + "inputNode": { "id": -10, "type": "subgraph/input", @@ -414,7 +402,7 @@ "flags": {}, "mode": 0 }, - + "inputs": [ { "name": "input_0", "type": "number", "pos": [0, 0] }, { "name": "input_1", "type": "string", "pos": [0, 1] }, @@ -441,4 +429,4 @@ ], "widgets": [] } -} \ No newline at end of file +} diff --git a/src/lib/litegraph/src/subgraph/subgraphUtils.ts b/src/lib/litegraph/src/subgraph/subgraphUtils.ts index 38f74efaa..518869503 100644 --- a/src/lib/litegraph/src/subgraph/subgraphUtils.ts +++ b/src/lib/litegraph/src/subgraph/subgraphUtils.ts @@ -4,6 +4,7 @@ import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' import type { ResolvedConnection } from '@/lib/litegraph/src/LLink' import { Reroute } from '@/lib/litegraph/src/Reroute' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID @@ -259,10 +260,29 @@ export function groupResolvedByOutput( return groupedByOutput } +function mapReroutes( + link: SerialisableLLink, + reroutes: Map +) { + let child: SerialisableLLink | Reroute = link + let nextReroute = + child.parentId === undefined ? undefined : reroutes.get(child.parentId) + + while (child.parentId !== undefined && nextReroute) { + child = nextReroute + nextReroute = + child.parentId === undefined ? undefined : reroutes.get(child.parentId) + } + + const lastId = child.parentId + child.parentId = undefined + return lastId +} export function mapSubgraphInputsAndLinks( resolvedInputLinks: ResolvedConnection[], - links: SerialisableLLink[] + links: SerialisableLLink[], + reroutes: Map ): SubgraphIO[] { // Group matching links const groupedByOutput = groupResolvedByOutput(resolvedInputLinks) @@ -279,8 +299,10 @@ export function mapSubgraphInputsAndLinks( if (!input) continue const linkData = link.asSerialisable() + link.parentId = mapReroutes(link, reroutes) linkData.origin_id = SUBGRAPH_INPUT_ID linkData.origin_slot = inputs.length + links.push(linkData) inputLinks.push(linkData) } @@ -340,7 +362,8 @@ export function mapSubgraphInputsAndLinks( */ export function mapSubgraphOutputsAndLinks( resolvedOutputLinks: ResolvedConnection[], - links: SerialisableLLink[] + links: SerialisableLLink[], + reroutes: Map ): SubgraphIO[] { // Group matching links const groupedByOutput = groupResolvedByOutput(resolvedOutputLinks) @@ -355,10 +378,11 @@ export function mapSubgraphOutputsAndLinks( const { link, output } = resolved if (!output) continue - // Link const linkData = link.asSerialisable() + linkData.parentId = mapReroutes(link, reroutes) linkData.target_id = SUBGRAPH_OUTPUT_ID linkData.target_slot = outputs.length + links.push(linkData) outputLinks.push(linkData) } diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 85794d674..d3c249ba3 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -1,3 +1,5 @@ +import type { Bounds } from '@/renderer/core/layout/types' + import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces' import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph' import type { CanvasPointerEvent } from './events' @@ -55,7 +57,7 @@ interface IWidgetKnobOptions extends IWidgetOptions { } export interface IWidgetAssetOptions extends IWidgetOptions { - openModal: () => void + openModal: (widget: IBaseWidget) => void } /** @@ -88,6 +90,8 @@ export type IWidget = | ISelectButtonWidget | ITextareaWidget | IAssetWidget + | IImageCropWidget + | IBoundingBoxWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -259,6 +263,18 @@ export interface IAssetWidget extends IBaseWidget< value: string } +/** Image crop widget for cropping image */ +export interface IImageCropWidget extends IBaseWidget { + type: 'imagecrop' + value: Bounds +} + +/** Bounding box widget for defining regions with numeric inputs */ +export interface IBoundingBoxWidget extends IBaseWidget { + type: 'boundingbox' + value: Bounds +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/utils/textUtils.test.ts b/src/lib/litegraph/src/utils/textUtils.test.ts index 8ca101f61..4d2bbbca0 100644 --- a/src/lib/litegraph/src/utils/textUtils.test.ts +++ b/src/lib/litegraph/src/utils/textUtils.test.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest' import { describe, expect, it, vi } from 'vitest' import { truncateText } from '@/lib/litegraph/src/litegraph' @@ -5,8 +6,13 @@ import { truncateText } from '@/lib/litegraph/src/litegraph' describe('truncateText', () => { const createMockContext = (charWidth: number = 10) => { return { - measureText: vi.fn((text: string) => ({ width: text.length * charWidth })) - } as unknown as CanvasRenderingContext2D + measureText: vi.fn( + (text: string) => + ({ + width: text.length * charWidth + }) as TextMetrics + ) + } as Partial as CanvasRenderingContext2D } it('should return original text if it fits within maxWidth', () => { @@ -57,7 +63,7 @@ describe('truncateText', () => { // Verify binary search efficiency - should not measure every possible substring // Binary search for 100 chars should take around log2(100) ≈ 7 iterations // Plus a few extra calls for measuring the full text and ellipsis - const callCount = (ctx.measureText as any).mock.calls.length + const callCount = (ctx.measureText as Mock).mock.calls.length expect(callCount).toBeLessThan(20) expect(callCount).toBeGreaterThan(5) }) diff --git a/src/lib/litegraph/src/widgets/AssetWidget.ts b/src/lib/litegraph/src/widgets/AssetWidget.ts index cc94a05a6..d836cdabf 100644 --- a/src/lib/litegraph/src/widgets/AssetWidget.ts +++ b/src/lib/litegraph/src/widgets/AssetWidget.ts @@ -53,6 +53,6 @@ export class AssetWidget override onClick() { //Open Modal - this.options.openModal() + this.options.openModal(this) } } diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index a580fc69a..9104c8a5b 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -1,3 +1,4 @@ +import { t } from '@/i18n' import { drawTextInArea } from '@/lib/litegraph/src/draw' import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' import type { Point } from '@/lib/litegraph/src/interfaces' @@ -141,6 +142,7 @@ export abstract class BaseWidget< // @ts-expect-error Prevent naming conflicts with custom nodes. labelBaseline, promoted, + linkedWidgets, ...safeValues } = widget @@ -227,6 +229,41 @@ export abstract class BaseWidget< if (showText && !this.computedDisabled) ctx.stroke() } + /** + * Draws a placeholder for widgets that only have a Vue implementation. + * @param ctx The canvas context + * @param options The options for drawing the widget + * @param label The label to display (e.g., "ImageCrop", "BoundingBox") + */ + protected drawVueOnlyWarning( + ctx: CanvasRenderingContext2D, + { width }: DrawWidgetOptions, + label: string + ): void { + const { y, height } = this + + ctx.save() + + ctx.fillStyle = this.background_color + ctx.fillRect(15, y, width - 30, height) + + ctx.strokeStyle = this.outline_color + ctx.strokeRect(15, y, width - 30, height) + + ctx.fillStyle = this.text_color + ctx.font = '11px monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + ctx.fillText( + `${label}: ${t('widgets.node2only')}`, + width / 2, + y + height / 2 + ) + + ctx.restore() + } + /** * A shared routine for drawing a label and value as text, truncated * if they exceed the available width. diff --git a/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts new file mode 100644 index 000000000..d571f744f --- /dev/null +++ b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts @@ -0,0 +1,22 @@ +import type { IBoundingBoxWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +/** + * Widget for defining bounding box regions. + * This widget only has a Vue implementation. + */ +export class BoundingBoxWidget + extends BaseWidget + implements IBoundingBoxWidget +{ + override type = 'boundingbox' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'BoundingBox') + } + + onClick(_options: WidgetEventOptions): void { + // This widget only has a Vue implementation + } +} diff --git a/src/lib/litegraph/src/widgets/ComboWidget.test.ts b/src/lib/litegraph/src/widgets/ComboWidget.test.ts index f080dbab7..e58a61cc8 100644 --- a/src/lib/litegraph/src/widgets/ComboWidget.test.ts +++ b/src/lib/litegraph/src/widgets/ComboWidget.test.ts @@ -389,8 +389,9 @@ describe('ComboWidget', () => { node.size = [200, 30] const mockContextMenu = vi.fn() - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -426,8 +427,9 @@ describe('ComboWidget', () => { node.size = [200, 30] const mockContextMenu = vi.fn() - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -463,8 +465,9 @@ describe('ComboWidget', () => { .mockImplementation(function (_values, options) { capturedCallback = options.callback }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu const setValueSpy = vi.spyOn(widget, 'setValue') widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -506,8 +509,9 @@ describe('ComboWidget', () => { .mockImplementation(function (_values, options) { capturedCallback = options.callback }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu const setValueSpy = vi.spyOn(widget, 'setValue') widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -538,8 +542,9 @@ describe('ComboWidget', () => { node.size = [200, 30] const mockContextMenu = vi.fn() - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -572,8 +577,9 @@ describe('ComboWidget', () => { node.size = [200, 30] const mockContextMenu = vi.fn() - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -768,8 +774,9 @@ describe('ComboWidget', () => { .mockImplementation(function () { this.addItem = mockAddItem }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) // Should show formatted labels in dropdown @@ -831,8 +838,9 @@ describe('ComboWidget', () => { capturedCallback = options.callback this.addItem = mockAddItem }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu const setValueSpy = vi.spyOn(widget, 'setValue') widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -885,8 +893,9 @@ describe('ComboWidget', () => { capturedCallback = options.callback this.addItem = mockAddItem }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -964,8 +973,9 @@ describe('ComboWidget', () => { .mockImplementation(function () { this.addItem = mockAddItem }) - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -1012,8 +1022,9 @@ describe('ComboWidget', () => { node.size = [200, 30] const mockContextMenu = vi.fn() - LiteGraph.ContextMenu = - mockContextMenu as unknown as typeof LiteGraph.ContextMenu + LiteGraph.ContextMenu = mockContextMenu as Partial< + typeof LiteGraph.ContextMenu + > as typeof LiteGraph.ContextMenu widget.onClick({ e: mockEvent, node, canvas: mockCanvas }) @@ -1051,7 +1062,8 @@ describe('ComboWidget', () => { createMockWidgetConfig({ name: 'mode', value: 'test', - options: { values: null as any } + // @ts-expect-error - Testing with intentionally invalid null value + options: { values: null } }), node ) diff --git a/src/lib/litegraph/src/widgets/ImageCropWidget.ts b/src/lib/litegraph/src/widgets/ImageCropWidget.ts new file mode 100644 index 000000000..a81cd580c --- /dev/null +++ b/src/lib/litegraph/src/widgets/ImageCropWidget.ts @@ -0,0 +1,22 @@ +import type { IImageCropWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +/** + * Widget for displaying an image crop preview. + * This widget only has a Vue implementation. + */ +export class ImageCropWidget + extends BaseWidget + implements IImageCropWidget +{ + override type = 'imagecrop' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'ImageCrop') + } + + onClick(_options: WidgetEventOptions): void { + // This widget only has a Vue implementation + } +} diff --git a/src/lib/litegraph/src/widgets/TextareaWidget.ts b/src/lib/litegraph/src/widgets/TextareaWidget.ts index f29ccc5e5..c6b83e13f 100644 --- a/src/lib/litegraph/src/widgets/TextareaWidget.ts +++ b/src/lib/litegraph/src/widgets/TextareaWidget.ts @@ -1,12 +1,10 @@ -import { t } from '@/i18n' - import type { ITextareaWidget } from '../types/widgets' import { BaseWidget } from './BaseWidget' import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' /** - * Widget for multi-line text input - * This is a widget that only has a Vue widgets implementation + * Widget for multi-line text input. + * This widget only has a Vue implementation. */ export class TextareaWidget extends BaseWidget @@ -15,35 +13,10 @@ export class TextareaWidget override type = 'textarea' as const drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { - const { width } = options - const { y, height } = this - - const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx - - ctx.fillStyle = this.background_color - ctx.fillRect(15, y, width - 30, height) - - ctx.strokeStyle = this.outline_color - ctx.strokeRect(15, y, width - 30, height) - - ctx.fillStyle = this.text_color - ctx.font = '11px monospace' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const text = `Textarea: ${t('widgets.node2only')}` - ctx.fillText(text, width / 2, y + height / 2) - - Object.assign(ctx, { - fillStyle, - strokeStyle, - textAlign, - textBaseline, - font - }) + this.drawVueOnlyWarning(ctx, options, 'Textarea') } onClick(_options: WidgetEventOptions): void { - // This is a widget that only has a Vue widgets implementation + // This widget only has a Vue implementation } } diff --git a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts index ee78d5919..78586cb6f 100644 --- a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts +++ b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts @@ -1,12 +1,10 @@ -import { t } from '@/i18n' - import type { ITreeSelectWidget } from '../types/widgets' import { BaseWidget } from './BaseWidget' import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' /** - * Widget for hierarchical tree selection - * This is a widget that only has a Vue widgets implementation + * Widget for hierarchical tree selection. + * This widget only has a Vue implementation. */ export class TreeSelectWidget extends BaseWidget @@ -15,35 +13,10 @@ export class TreeSelectWidget override type = 'treeselect' as const drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { - const { width } = options - const { y, height } = this - - const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx - - ctx.fillStyle = this.background_color - ctx.fillRect(15, y, width - 30, height) - - ctx.strokeStyle = this.outline_color - ctx.strokeRect(15, y, width - 30, height) - - ctx.fillStyle = this.text_color - ctx.font = '11px monospace' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const text = `TreeSelect: ${t('widgets.node2only')}` - ctx.fillText(text, width / 2, y + height / 2) - - Object.assign(ctx, { - fillStyle, - strokeStyle, - textAlign, - textBaseline, - font - }) + this.drawVueOnlyWarning(ctx, options, 'TreeSelect') } onClick(_options: WidgetEventOptions): void { - // This is a widget that only has a Vue widgets implementation + // This widget only has a Vue implementation } } diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 0e6a34fe5..37b906efb 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -11,6 +11,7 @@ import { toClass } from '@/lib/litegraph/src/utils/type' import { AssetWidget } from './AssetWidget' import { BaseWidget } from './BaseWidget' import { BooleanWidget } from './BooleanWidget' +import { BoundingBoxWidget } from './BoundingBoxWidget' import { ButtonWidget } from './ButtonWidget' import { ChartWidget } from './ChartWidget' import { ColorWidget } from './ColorWidget' @@ -18,6 +19,7 @@ import { ComboWidget } from './ComboWidget' import { FileUploadWidget } from './FileUploadWidget' import { GalleriaWidget } from './GalleriaWidget' import { ImageCompareWidget } from './ImageCompareWidget' +import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' import { LegacyWidget } from './LegacyWidget' import { MarkdownWidget } from './MarkdownWidget' @@ -50,6 +52,8 @@ export type WidgetTypeMap = { selectbutton: SelectButtonWidget textarea: TextareaWidget asset: AssetWidget + imagecrop: ImageCropWidget + boundingbox: BoundingBoxWidget [key: string]: BaseWidget } @@ -120,6 +124,10 @@ export function toConcreteWidget( return toClass(TextareaWidget, narrowedWidget, node) case 'asset': return toClass(AssetWidget, narrowedWidget, node) + case 'imagecrop': + return toClass(ImageCropWidget, narrowedWidget, node) + case 'boundingbox': + return toClass(BoundingBoxWidget, narrowedWidget, node) default: { if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) } diff --git a/src/locales/CONTRIBUTING.md b/src/locales/CONTRIBUTING.md index 3e05c10bc..1df16aa2d 100644 --- a/src/locales/CONTRIBUTING.md +++ b/src/locales/CONTRIBUTING.md @@ -10,6 +10,7 @@ ## Technical Process (Confirmed Working) ### Prerequisites + - Node.js installed - Git/GitHub knowledge - OpenAI API key (optional - CI will handle translations) @@ -19,6 +20,7 @@ **Time required: ~10 minutes** #### 1.1 Update `.i18nrc.cjs` + Add your language code to the `outputLocales` array: ```javascript @@ -30,10 +32,11 @@ module.exports = defineConfig({ '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 @@ -56,6 +59,7 @@ Add your language to the dropdown options: ``` #### 1.3 Update `src/i18n.ts` + Add imports for your new language files: ```typescript @@ -69,7 +73,7 @@ import zhTWSettings from './locales/zh-TW/settings.json' const messages = { en: buildLocale(en, enNodes, enCommands, enSettings), zh: buildLocale(zh, zhNodes, zhCommands, zhSettings), - 'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings), // Add this line + 'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings) // Add this line // ... other languages } ``` @@ -77,12 +81,14 @@ const messages = { ### Step 2: Generate Translation Files #### Option A: Local Generation (Optional) + ```bash # Only if you have OpenAI API key configured pnpm locale ``` #### Option B: Let CI Handle It (Recommended) + - Create your PR with the configuration changes above - **Important**: Translation files will be generated during release PRs, not feature PRs - Empty JSON files are fine - they'll be populated during the next release workflow @@ -96,6 +102,7 @@ pnpm dev # Start development server ``` **Testing checklist:** + - [ ] Language appears in ComfyUI Settings > Locale dropdown - [ ] Can select the new language without errors - [ ] Partial translations display correctly @@ -111,27 +118,32 @@ pnpm dev # Start development server ## What Happens in CI -Our automated translation workflow now runs on release PRs (version-bump-* branches) to improve development performance: +Our automated translation workflow now runs on release PRs (version-bump-\* branches) to improve development performance: ### For Feature PRs (Regular Development) + - **No automatic translations** - faster reviews and fewer conflicts - **English-only development** - new strings show in English until release - **Focus on functionality** - reviewers see only your actual changes -### For Release PRs (version-bump-* branches) +### For Release PRs (version-bump-\* branches) + 1. **Collects strings**: Scans the UI for translatable text -2. **Updates English files**: Ensures all strings are captured +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 the release PR with complete translations ### Manual Translation Updates + If urgent translation updates are needed outside of releases, maintainers can: + - Trigger the "Update Locales" workflow manually from GitHub Actions - The workflow supports manual dispatch for emergency translation updates ## 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) @@ -147,20 +159,25 @@ Each language has 4 translation files: ## 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 @@ -175,6 +192,7 @@ For regional variants (like zh-TW for Taiwan), use: ## 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 @@ -182,4 +200,4 @@ After your language is added: --- -*This process was tested and confirmed working with Traditional Chinese (Taiwan) addition.* \ No newline at end of file +_This process was tested and confirmed working with Traditional Chinese (Taiwan) addition._ diff --git a/src/locales/README.md b/src/locales/README.md index 047eed565..786fc0ae9 100644 --- a/src/locales/README.md +++ b/src/locales/README.md @@ -18,14 +18,17 @@ Our project supports multiple languages using `vue-i18n`. This allows users arou Want to add a new language to ComfyUI? See our detailed [Contributing Guide](./CONTRIBUTING.md) with step-by-step instructions and confirmed working process. ### 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 ### File Structure + Each language has 4 translation files in `src/locales/[language-code]/`: + - `main.json` - Main UI text -- `commands.json` - Command descriptions +- `commands.json` - Command descriptions - `settings.json` - Settings panel - `nodeDefs.json` - Node definitions diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index e3d369349..615787903 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -24,6 +24,7 @@ "assets": "الأصول", "baseModels": "النماذج الأساسية", "browseAssets": "تصفح الأصول", + "byType": "حسب النوع", "checkpoints": "نقاط التحقق", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "مثال:", @@ -45,6 +46,10 @@ "failed": "فشل التنزيل", "inProgress": "جاري تنزيل {assetName}..." }, + "emptyImported": { + "canImport": "لا توجد نماذج مستوردة بعد. انقر على \"استيراد نموذج\" لإضافة نموذجك الخاص.", + "restricted": "النماذج الشخصية متاحة فقط لمستوى Creator وما فوق." + }, "errorFileTooLarge": "الملف يتجاوز الحد الأقصى المسموح به للحجم", "errorFormatNotAllowed": "يسمح فقط بصيغة SafeTensor", "errorModelTypeNotSupported": "نوع النموذج هذا غير مدعوم", @@ -61,6 +66,7 @@ "finish": "إنهاء", "genericLinkPlaceholder": "الصق الرابط هنا", "importAnother": "استيراد آخر", + "imported": "مستوردة", "jobId": "معرّف المهمة", "loadingModels": "جارٍ تحميل {type}...", "maxFileSize": "الحد الأقصى لحجم الملف: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "نموذج ثلاثي الأبعاد" }, "modelAssociatedWithLink": "النموذج المرتبط بالرابط الذي قدمته:", + "modelInfo": { + "addBaseModel": "أضف نموذجًا أساسيًا...", + "addTag": "أضف وسمًا...", + "additionalTags": "وسوم إضافية", + "baseModelUnknown": "النموذج الأساسي غير معروف", + "basicInfo": "معلومات أساسية", + "compatibleBaseModels": "نماذج أساسية متوافقة", + "description": "الوصف", + "descriptionNotSet": "لم يتم تعيين وصف", + "descriptionPlaceholder": "أضف وصفًا لهذا النموذج...", + "displayName": "اسم العرض", + "editDisplayName": "تعديل اسم العرض", + "fileName": "اسم الملف", + "modelDescription": "وصف النموذج", + "modelTagging": "تصنيف النموذج", + "modelType": "نوع النموذج", + "noAdditionalTags": "لا توجد وسوم إضافية", + "selectModelPrompt": "اختر نموذجًا لعرض معلوماته", + "selectModelType": "اختر نوع النموذج...", + "source": "المصدر", + "title": "معلومات النموذج", + "triggerPhrases": "عبارات التفعيل", + "viewOnSource": "عرض على {source}" + }, "modelName": "اسم النموذج", "modelNamePlaceholder": "أدخل اسمًا لهذا النموذج", "modelTypeSelectorLabel": "ما نوع هذا النموذج؟", @@ -238,6 +268,12 @@ "title": "إنشاء حساب" } }, + "boundingBox": { + "height": "الارتفاع", + "width": "العرض", + "x": "س", + "y": "ص" + }, "breadcrumbsMenu": { "clearWorkflow": "مسح سير العمل", "deleteBlueprint": "حذف المخطط", @@ -678,6 +714,7 @@ "clearAll": "مسح الكل", "clearFilters": "مسح الفلاتر", "close": "إغلاق", + "closeDialog": "إغلاق الحوار", "color": "اللون", "comfy": "Comfy", "comfyOrgLogoAlt": "شعار ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "التحكم قبل التوليد", "copied": "تم النسخ", "copy": "نسخ", + "copyAll": "نسخ الكل", "copyJobId": "نسخ معرف المهمة", "copyToClipboard": "نسخ إلى الحافظة", "copyURL": "نسخ الرابط", @@ -756,6 +794,8 @@ "goToNode": "الانتقال إلى العقدة", "graphNavigation": "التنقل في الرسم البياني", "halfSpeed": "0.5x", + "hideLeftPanel": "إخفاء اللوحة اليسرى", + "hideRightPanel": "إخفاء اللوحة اليمنى", "icon": "أيقونة", "imageFailedToLoad": "فشل تحميل الصورة", "imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور", @@ -797,6 +837,7 @@ "name": "الاسم", "newFolder": "مجلد جديد", "next": "التالي", + "nightly": "NIGHTLY", "no": "لا", "noAudioRecorded": "لم يتم تسجيل أي صوت", "noItems": "لا توجد عناصر", @@ -811,6 +852,7 @@ "nodeSlotsError": "خطأ في فتحات العقدة", "nodeWidgetsError": "خطأ في عناصر واجهة العقدة", "nodes": "العُقَد", + "nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة", "nodesRunning": "العُقَد قيد التشغيل", "none": "لا شيء", "nothingToCopy": "لا يوجد ما يمكن نسخه", @@ -885,7 +927,9 @@ "selectedFile": "الملف المحدد", "setAsBackground": "تعيين كخلفية", "settings": "الإعدادات", + "showLeftPanel": "إظهار اللوحة اليسرى", "showReport": "عرض التقرير", + "showRightPanel": "إظهار اللوحة اليمنى", "singleSelectDropdown": "قائمة منسدلة اختيار واحد", "sort": "فرز", "source": "المصدر", @@ -908,6 +952,7 @@ "updating": "جارٍ التحديث", "upload": "رفع", "usageHint": "تلميح الاستخدام", + "use": "استخدم", "user": "المستخدم", "versionMismatchWarning": "تحذير توافق الإصدارات", "versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.", @@ -915,11 +960,10 @@ "videoPreview": "معاينة الفيديو - استخدم مفاتيح الأسهم للتنقل بين الفيديوهات", "viewImageOfTotal": "عرض الصورة {index} من {total}", "viewVideoOfTotal": "عرض الفيديو {index} من {total}", - "vitePreloadErrorMessage": "تم إصدار نسخة جديدة من التطبيق. هل ترغب في إعادة التحميل؟\nإذا لم تفعل، قد لا تعمل بعض أجزاء التطبيق كما هو متوقع.\nيمكنك رفض وحفظ تقدمك قبل إعادة التحميل.", - "vitePreloadErrorTitle": "إصدار جديد متاح", "volume": "مستوى الصوت", "warning": "تحذير", - "workflow": "سير العمل" + "workflow": "سير العمل", + "you": "أنت" }, "graphCanvasMenu": { "fitView": "ملائمة العرض", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "لا توجد صور للمقارنة" }, + "imageCrop": { + "cropPreviewAlt": "معاينة الاقتصاص", + "loading": "جارٍ التحميل...", + "noInputImage": "لا توجد صورة إدخال متصلة" + }, "importFailed": { "copyError": "خطأ في النسخ", "title": "فشل الاستيراد" @@ -1606,17 +1655,25 @@ "title": "سير العمل هذا يحتوي على عقد مفقودة" } }, + "nightly": { + "badge": { + "label": "إصدار معاينة", + "tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات." + } + }, "nodeCategories": { "": "", "3d": "ثلاثي الأبعاد", "3d_models": "نماذج ثلاثية الأبعاد", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "بايت دانس", "Gemini": "جيميني", "Ideogram": "إيديوغرام", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "مون فالي ماري", @@ -1627,11 +1684,13 @@ "Runway": "رن واي", "Sora": "سورا", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "تريبو", "Veo": "Veo", "Vidu": "فيدو", "Wan": "وان", + "WaveSpeed": "WaveSpeed", "_for_testing": "_للاختبار", "advanced": "متقدم", "animation": "الرسوم المتحركة", @@ -1841,6 +1900,7 @@ }, "groupSettings": "إعدادات المجموعة", "groups": "المجموعات", + "hideAdvancedInputsButton": "إخفاء المدخلات المتقدمة", "hideInput": "إخفاء المدخل", "info": "معلومات", "inputs": "المدخلات", @@ -2073,6 +2133,7 @@ "NodeLibrary": "مكتبة العقد", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "تفضيلات الإشعارات", + "Other": "أخرى", "PLY": "PLY", "PlanCredits": "الخطة والاعتمادات", "Pointer": "المؤشر", @@ -2092,7 +2153,8 @@ "Vue Nodes": "عقد Vue", "VueNodes": "عقد Vue", "Window": "النافذة", - "Workflow": "سير العمل" + "Workflow": "سير العمل", + "Workspace": "مساحة العمل" }, "shape": { "CARD": "بطاقة", @@ -2118,12 +2180,14 @@ "viewControls": "عناصر تحكم العرض" }, "sideToolbar": { + "activeJobStatus": "المهمة النشطة: {status}", "assets": "الأصول", "backToAssets": "العودة إلى جميع الأصول", "browseTemplates": "تصفح القوالب المثال", "downloads": "التنزيلات", "generatedAssetsHeader": "الأصول المُولدة", "helpCenter": "مركز المساعدة", + "importedAssetsHeader": "الأصول المستوردة", "labels": { "assets": "الأصول", "console": "وحدة التحكم", @@ -2168,6 +2232,7 @@ "queue": "قائمة الانتظار", "queueProgressOverlay": { "activeJobs": "{count} مهمة نشطة | {count} مهام نشطة", + "activeJobsShort": "{count} نشط | {count} نشط", "activeJobsSuffix": "مهام نشطة", "cancelJobTooltip": "إلغاء المهمة", "clearHistory": "مسح سجل قائمة الانتظار", @@ -2256,9 +2321,15 @@ "beta": "نسخة تجريبية", "billedMonthly": "يتم الفوترة شهريًا", "billedYearly": "{total} يتم الفوترة سنويًا", + "billingComingSoon": { + "message": "سيتم إطلاق الفوترة الجماعية قريباً. ستتمكن من الاشتراك في خطة لمساحة العمل الخاصة بك مع تسعير لكل مستخدم. ترقبوا التحديثات.", + "title": "قريباً" + }, + "cancelSubscription": "إلغاء الاشتراك", "changeTo": "تغيير إلى {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "شعار Comfy Cloud", + "contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك", "contactUs": "تواصل معنا", "creditsRemainingThisMonth": "الرصيد المتبقي لهذا الشهر", "creditsRemainingThisYear": "الرصيد المتبقي لهذا العام", @@ -2271,6 +2342,7 @@ "haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟", "invoiceHistory": "سجل الفواتير", "learnMore": "معرفة المزيد", + "managePayment": "إدارة الدفع", "managePlan": "إدارة الخطة", "manageSubscription": "إدارة الاشتراك", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "الاشتراك في Comfy Cloud", "subscribeToRun": "اشتراك", "subscribeToRunFull": "الاشتراك للتشغيل", + "subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة", "tierNameYearly": "{name} سنوي", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "عرض المزيد من التفاصيل", "viewMoreDetailsPlans": "عرض المزيد من التفاصيل حول الخطط والأسعار", "viewUsageHistory": "عرض سجل الاستخدام", + "workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة", "yearly": "سنوي", "yearlyCreditsLabel": "إجمالي الرصيد السنوي", "yearlyDiscount": "خصم 20%", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "فشل في تحميل النموذج ثلاثي الأبعاد", "failedToPurchaseCredits": "فشل في شراء الرصيد: {error}", "failedToQueue": "فشل في الإضافة إلى قائمة الانتظار", + "failedToSaveDraft": "فشل في حفظ مسودة سير العمل", "failedToToggleCamera": "فشل في تبديل الكاميرا", "failedToToggleGrid": "فشل في تبديل الشبكة", "failedToUpdateBackgroundColor": "فشل في تحديث لون الخلفية", @@ -2486,7 +2561,8 @@ "notSet": "غير محدد", "provider": "مزود تسجيل الدخول", "title": "إعدادات المستخدم", - "updatePassword": "تحديث كلمة المرور" + "updatePassword": "تحديث كلمة المرور", + "workspaceSettings": "إعدادات مساحة العمل" }, "validation": { "descriptionRequired": "الوصف مطلوب", @@ -2577,6 +2653,9 @@ "saveWorkflow": "حفظ سير العمل" }, "workspace": { + "addedToWorkspace": "تمت إضافتك إلى {workspaceName}", + "inviteAccepted": "تم قبول الدعوة", + "inviteFailed": "فشل في قبول الدعوة", "unsavedChanges": { "message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟", "title": "تغييرات غير محفوظة" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "لم يتم العثور على مساحة العمل" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "إنشاء", + "message": "تتيح مساحات العمل للأعضاء مشاركة رصيد واحد. ستصبح المالك بعد الإنشاء.", + "nameLabel": "اسم مساحة العمل*", + "namePlaceholder": "أدخل اسم مساحة العمل", + "title": "إنشاء مساحة عمل جديدة" + }, + "dashboard": { + "placeholder": "إعدادات مساحة عمل لوحة التحكم" + }, + "deleteDialog": { + "message": "سيتم فقدان أي أرصدة غير مستخدمة أو أصول غير محفوظة. لا يمكن التراجع عن هذا الإجراء.", + "messageWithName": "حذف \"{name}\"؟ سيتم فقدان أي أرصدة غير مستخدمة أو أصول غير محفوظة. لا يمكن التراجع عن هذا الإجراء.", + "title": "حذف هذه المساحة؟" + }, + "editWorkspaceDialog": { + "nameLabel": "اسم مساحة العمل", + "save": "حفظ", + "title": "تعديل تفاصيل مساحة العمل" + }, + "invite": "دعوة", + "inviteLimitReached": "لقد وصلت إلى الحد الأقصى وهو ٥٠ عضواً", + "inviteMember": "دعوة عضو", + "inviteMemberDialog": { + "createLink": "إنشاء الرابط", + "linkCopied": "تم النسخ", + "linkCopyFailed": "فشل في نسخ الرابط", + "linkStep": { + "copyLink": "نسخ الرابط", + "done": "تم", + "message": "تأكد من أن حسابه يستخدم هذا البريد الإلكتروني.", + "title": "أرسل هذا الرابط إلى الشخص" + }, + "message": "أنشئ رابط دعوة قابل للمشاركة لإرساله إلى شخص ما", + "placeholder": "أدخل بريد الشخص الإلكتروني", + "title": "دعوة شخص إلى هذه المساحة" + }, + "leaveDialog": { + "leave": "مغادرة", + "message": "لن تتمكن من الانضمام مرة أخرى إلا إذا تواصلت مع مالك مساحة العمل.", + "title": "مغادرة هذه المساحة؟" + }, + "members": { + "actions": { + "copyLink": "نسخ رابط الدعوة", + "removeMember": "إزالة العضو", + "revokeInvite": "إلغاء الدعوة" + }, + "columns": { + "expiryDate": "تاريخ الانتهاء", + "inviteDate": "تاريخ الدعوة", + "joinDate": "تاريخ الانضمام" + }, + "createNewWorkspace": "أنشئ واحدة جديدة.", + "membersCount": "{count}/٥٠ عضواً", + "noInvites": "لا توجد دعوات معلقة", + "noMembers": "لا يوجد أعضاء", + "pendingInvitesCount": "{count} دعوة معلقة | {count} دعوات معلقة", + "personalWorkspaceMessage": "لا يمكنك دعوة أعضاء آخرين إلى مساحة العمل الشخصية حالياً. لإضافة أعضاء إلى مساحة عمل،", + "tabs": { + "active": "نشط", + "pendingCount": "معلق ({count})" + } + }, + "menu": { + "deleteWorkspace": "حذف مساحة العمل", + "deleteWorkspaceDisabledTooltip": "يرجى إلغاء الاشتراك النشط لمساحة العمل أولاً", + "editWorkspace": "تعديل تفاصيل مساحة العمل", + "leaveWorkspace": "مغادرة مساحة العمل" + }, + "removeMemberDialog": { + "error": "فشل في إزالة العضو", + "message": "سيتم إزالة هذا العضو من مساحة العمل الخاصة بك. لن يتم استرداد الأرصدة التي استخدمها.", + "remove": "إزالة العضو", + "success": "تمت إزالة العضو", + "title": "إزالة هذا العضو؟" + }, + "revokeInviteDialog": { + "message": "لن يتمكن هذا العضو من الانضمام إلى مساحة العمل الخاصة بك بعد الآن. سيتم إبطال رابط الدعوة الخاص به.", + "revoke": "إلغاء الدعوة", + "title": "إلغاء دعوة هذا الشخص؟" + }, + "tabs": { + "dashboard": "لوحة التحكم", + "membersCount": "الأعضاء ({count})", + "planCredits": "الخطة والأرصدة" + }, + "toast": { + "failedToCreateWorkspace": "فشل في إنشاء مساحة العمل", + "failedToDeleteWorkspace": "فشل في حذف مساحة العمل", + "failedToFetchWorkspaces": "فشل في تحميل مساحات العمل", + "failedToLeaveWorkspace": "فشل في مغادرة مساحة العمل", + "failedToUpdateWorkspace": "فشل في تحديث مساحة العمل", + "workspaceCreated": { + "message": "اشترك في خطة، وادعُ زملاءك، وابدأ التعاون.", + "subscribe": "اشترك", + "title": "تم إنشاء مساحة العمل" + }, + "workspaceDeleted": { + "message": "تم حذف مساحة العمل نهائياً.", + "title": "تم حذف مساحة العمل" + }, + "workspaceLeft": { + "message": "لقد غادرت مساحة العمل.", + "title": "تمت مغادرة مساحة العمل" + }, + "workspaceUpdated": { + "message": "تم حفظ تفاصيل مساحة العمل.", + "title": "تم تحديث مساحة العمل" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "إنشاء مساحة عمل جديدة", + "maxWorkspacesReached": "يمكنك امتلاك ١٠ مساحات عمل فقط. احذف واحدة لإنشاء مساحة جديدة.", + "personal": "شخصي", + "roleMember": "عضو", + "roleOwner": "المالك", + "subscribe": "اشترك", + "switchWorkspace": "تبديل مساحة العمل" + }, "zoomControls": { "hideMinimap": "إخفاء الخريطة المصغرة", "label": "عناصر التحكم في التكبير", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index 5e81dd838..495bce4e9 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "حرر الصور باستخدام أحدث نموذج من Bria", + "display_name": "تحرير صورة Bria", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "guidance_scale": { + "name": "مقياس التوجيه", + "tooltip": "القيمة الأعلى تجعل الصورة تتبع التوجيه بشكل أدق." + }, + "image": { + "name": "الصورة" + }, + "mask": { + "name": "القناع", + "tooltip": "إذا لم يتم تحديده، سيتم تطبيق التحرير على الصورة بالكامل." + }, + "model": { + "name": "النموذج" + }, + "moderation": { + "name": "الإشراف", + "tooltip": "إعدادات الإشراف" + }, + "moderation_prompt_content_moderation": { + "name": "إشراف محتوى التوجيه" + }, + "moderation_visual_input_moderation": { + "name": "إشراف الإدخال البصري" + }, + "moderation_visual_output_moderation": { + "name": "إشراف الإخراج البصري" + }, + "negative_prompt": { + "name": "توجيه سلبي" + }, + "prompt": { + "name": "التوجيه", + "tooltip": "تعليمات لتحرير الصورة" + }, + "seed": { + "name": "البذرة" + }, + "steps": { + "name": "الخطوات" + }, + "structured_prompt": { + "name": "توجيه منظم", + "tooltip": "سلسلة نصية تحتوي على توجيه التحرير المنظم بصيغة JSON. استخدم هذا بدلاً من التوجيه المعتاد للتحكم الدقيق والبرمجي." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "توجيه منظم", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.", "display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو", @@ -351,6 +413,10 @@ "name": "الإطار_الأول", "tooltip": "الإطار الأول الذي سيتم استخدامه للفيديو." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "last_frame": { "name": "الإطار_الأخير", "tooltip": "الإطار الأخير الذي سيتم استخدامه للفيديو." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "تحرير الصور باستخدام نماذج ByteDance عبر واجهة برمجة التطبيقات بناءً على المطالبة النصية", - "display_name": "تحرير الصور من ByteDance", - "inputs": { - "control_after_generate": { - "name": "التحكم بعد التوليد" - }, - "guidance_scale": { - "name": "مقياس التوجيه", - "tooltip": "القيمة الأعلى تجعل الصورة تتبع النص الموجه بشكل أكبر" - }, - "image": { - "name": "الصورة", - "tooltip": "الصورة الأساسية للتحرير" - }, - "model": { - "name": "النموذج" - }, - "prompt": { - "name": "المطالبة النصية", - "tooltip": "تعليمات لتحرير الصورة" - }, - "seed": { - "name": "البذرة", - "tooltip": "البذرة المستخدمة في التوليد" - }, - "watermark": { - "name": "علامة مائية", - "tooltip": "ما إذا كان سيتم إضافة علامة مائية \"تم إنشاؤها بالذكاء الاصطناعي\" إلى الصورة" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "إنشاء الصور باستخدام نماذج ByteDance عبر API استنادًا إلى النص الموجه", "display_name": "صورة ByteDance", @@ -527,6 +556,10 @@ "name": "duration", "tooltip": "مدة الفيديو الناتج بالثواني." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "الإطار الأول الذي سيتم استخدامه للفيديو." @@ -634,6 +667,10 @@ "name": "المدة", "tooltip": "مدة الفيديو الناتج بالثواني." }, + "generate_audio": { + "name": "توليد الصوت", + "tooltip": "يتم تجاهل هذا المعامل لجميع النماذج باستثناء seedance-1-5-pro." + }, "model": { "name": "النموذج" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "اختيار" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "الفهرس", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "تحميل نموذج ControlNet (فرق)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "تحميل ثلاثي الأبعاد", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "الارتفاع" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "ملف النموذج" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "العرض" } @@ -6270,13 +6306,11 @@ "description": "تحميل صورة من مجلد المخرجات. عند الضغط على زر التحديث، سيقوم العقدة بتحديث قائمة الصور واختيار أول صورة تلقائياً لتسهيل التكرار.", "display_name": "تحميل صورة (من المخرجات)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "صورة" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "اختر ملف للتحميل" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "تطبيق LoRA في وضع التجاوز. على عكس LoRA العادي، لا يقوم هذا بتعديل أوزان النموذج - بل يحقن حساب LoRA أثناء التمرير الأمامي. مفيد في سيناريوهات التدريب.", + "display_name": "تحميل LoRA (تجاوز) (لأغراض التصحيح)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "نموذج CLIP الذي سيتم تطبيق LoRA عليه." + }, + "lora_name": { + "name": "اسم LoRA", + "tooltip": "اسم LoRA." + }, + "model": { + "name": "النموذج", + "tooltip": "نموذج الانتشار الذي سيتم تطبيق LoRA عليه." + }, + "strength_clip": { + "name": "قوة التعديل على CLIP", + "tooltip": "مدى قوة تعديل نموذج CLIP. يمكن أن تكون هذه القيمة سالبة." + }, + "strength_model": { + "name": "قوة التعديل على النموذج", + "tooltip": "مدى قوة تعديل نموذج الانتشار. يمكن أن تكون هذه القيمة سالبة." + } + }, + "outputs": { + "0": { + "tooltip": "نموذج الانتشار المعدل." + }, + "1": { + "tooltip": "نموذج CLIP المعدل." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "تطبيق LoRA في وضع التجاوز. على عكس LoRA العادي، لا يقوم هذا بتعديل أوزان النموذج - بل يحقن حساب LoRA أثناء التمرير الأمامي. مفيد في سيناريوهات التدريب.", + "display_name": "تحميل LoRA (تجاوز، النموذج فقط) (لأغراض التصحيح)", + "inputs": { + "lora_name": { + "name": "اسم LoRA" + }, + "model": { + "name": "النموذج" + }, + "strength_model": { + "name": "قوة التعديل على النموذج" + } + }, + "outputs": { + "0": { + "tooltip": "نموذج الانتشار المعدل." + } + } + }, "LoraLoaderModelOnly": { "description": "يُستخدم LoRA لتعديل نماذج الانتشار و CLIP، وتغيير طريقة إزالة الضجيج من الكامن مثل تطبيق الأنماط. يمكن ربط عدة عقد LoRA معاً.", "display_name": "تحميل LoRA (نموذج فقط)", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "إعادة إضاءة صورة مع تعديلات الإضاءة وخيار نقل الإضاءة من مرجع.", + "display_name": "إعادة إضاءة الصورة Magnific", + "inputs": { + "advanced_settings": { + "name": "إعدادات متقدمة", + "tooltip": "خيارات ضبط دقيق للتحكم المتقدم في الإضاءة." + }, + "change_background": { + "name": "تغيير الخلفية", + "tooltip": "يعدل الخلفية بناءً على التوجيه/المرجع." + }, + "image": { + "name": "الصورة", + "tooltip": "الصورة التي سيتم إعادة إضاءتها." + }, + "interpolate_from_original": { + "name": "التقريب من الأصل", + "tooltip": "يقيّد حرية التوليد لمطابقة الأصل بشكل أكبر." + }, + "light_transfer_strength": { + "name": "شدة نقل الإضاءة", + "tooltip": "شدة تطبيق نقل الإضاءة." + }, + "preserve_details": { + "name": "الحفاظ على التفاصيل", + "tooltip": "يحافظ على النسيج والتفاصيل الدقيقة من الأصل." + }, + "prompt": { + "name": "التوجيه", + "tooltip": "إرشادات وصفية للإضاءة. يدعم تدوين التأكيد (١-١.٤)." + }, + "reference_image": { + "name": "صورة مرجعية", + "tooltip": "صورة مرجعية اختيارية لنقل الإضاءة منها." + }, + "style": { + "name": "النمط", + "tooltip": "تفضيل النتيجة الأسلوبية." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "تحسين البشرة للصور الشخصية مع أوضاع معالجة متعددة.", + "display_name": "محسّن البشرة للصورة Magnific", + "inputs": { + "image": { + "name": "الصورة", + "tooltip": "صورة البورتريه التي سيتم تحسينها." + }, + "mode": { + "name": "الوضع", + "tooltip": "وضع المعالجة: إبداعي للتعزيز الفني، واقعي للحفاظ على المظهر الأصلي، مرن للتحسين المستهدف." + }, + "sharpen": { + "name": "حدة التوضيح", + "tooltip": "مستوى شدة التوضيح." + }, + "smart_grain": { + "name": "الحبيبات الذكية", + "tooltip": "مستوى شدة الحبيبات الذكية." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "نقل النمط من صورة مرجعية إلى صورتك المدخلة.", + "display_name": "نقل نمط الصورة Magnific", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "اختيار محرك المعالجة." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "عند التعطيل، توقع أن تقدم كل عملية توليد درجة من العشوائية، مما يؤدي إلى نتائج أكثر تنوعًا." + }, + "flavor": { + "name": "flavor", + "tooltip": "نوع نقل النمط." + }, + "image": { + "name": "image", + "tooltip": "الصورة التي سيتم تطبيق نقل النمط عليها." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "تفعيل وضع البورتريه لتحسينات الوجه." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "الصورة المرجعية لاستخلاص النمط منها." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "يحافظ على بنية الصورة الأصلية." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "نسبة قوة النمط." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "تعديل التوجيه للتركيز أكثر على 'اتجاه' النص الإيجابي بدلاً من الفرق بين النص السلبي.", "display_name": "ماهيرو لطيفة جداً وتستحق دالة توجيه أفضل!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "معاينة أي", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "المصدر" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "تغيير حجم صورة أو قناع باستخدام طرق تحجيم مختلفة.", "display_name": "تغيير حجم الصورة/القناع", "inputs": { "input": { "name": "الإدخال" }, "resize_type": { - "name": "نوع تغيير الحجم" + "name": "نوع تغيير الحجم", + "tooltip": "اختر طريقة تغيير الحجم: حسب الأبعاد الدقيقة، عامل التحجيم، مطابقة صورة أخرى، إلخ." }, - "resize_type_multiplier": { - "name": "المضاعف" + "resize_type_crop": { + "name": "قص" + }, + "resize_type_height": { + "name": "الارتفاع" + }, + "resize_type_width": { + "name": "العرض" }, "scale_method": { - "name": "طريقة التحجيم" + "name": "طريقة التحجيم", + "tooltip": "خوارزمية الاستيفاء. 'area' هي الأفضل لتصغير الحجم، و'lanczos' لتكبير الحجم، و'nearest-exact' لفن البكسل." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: من صورة إلى نموذج (احترافي)", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "face_count": { + "name": "عدد الأوجه" + }, + "generate_type": { + "name": "نوع التوليد" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "image": { + "name": "الصورة" + }, + "image_back": { + "name": "الصورة الخلفية" + }, + "image_left": { + "name": "الصورة اليسرى" + }, + "image_right": { + "name": "الصورة اليمنى" + }, + "model": { + "name": "النموذج", + "tooltip": "خيار LowPoly غير متوفر لنموذج `3.1`." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "name": "ملف النموذج", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: من نص إلى نموذج (احترافي)", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "face_count": { + "name": "عدد الأوجه" + }, + "generate_type": { + "name": "نوع التوليد" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "model": { + "name": "النموذج", + "tooltip": "خيار LowPoly غير متوفر لنموذج `3.1`." + }, + "prompt": { + "name": "الموجه", + "tooltip": "يدعم حتى ١٠٢٤ حرفاً." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "name": "ملف النموذج", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "تغيير حجم الصور تلقائياً" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "الصورة ١" + }, + "image2": { + "name": "الصورة ٢" + }, + "image3": { + "name": "الصورة ٣" + }, + "image_encoder": { + "name": "مُرمّز الصورة" + }, + "prompt": { + "name": "التوجيه" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "تحويل النص إلى أحرف صغيرة", "inputs": { @@ -13643,6 +13970,10 @@ "name": "وضع الدلو", "tooltip": "تمكين وضع دلو الدقة. عند التمكين، يتوقع وجود بيانات كامنة مجمعة مسبقًا من عقدة ResolutionBucket." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "تفعيل وضع التجاوز أثناء التدريب. عند التفعيل، يتم تطبيق المحولات عبر forward hooks بدلاً من تعديل الأوزان. مفيد للنماذج المحوّلة حيث لا يمكن تعديل الأوزان مباشرة." + }, "control_after_generate": { "name": "التحكم بعد التوليد" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "مخرجات ترميز الصوت ١" + }, + "audio_scale": { + "name": "مقياس الصوت" + }, + "clip_vision_output": { + "name": "مخرجات clip للرؤية" + }, + "height": { + "name": "الارتفاع" + }, + "length": { + "name": "الطول" + }, + "mode": { + "name": "الوضع" + }, + "model": { + "name": "النموذج" + }, + "model_patch": { + "name": "تصحيح النموذج" + }, + "motion_frame_count": { + "name": "عدد إطارات الحركة", + "tooltip": "عدد الإطارات السابقة المستخدمة كسياق للحركة." + }, + "negative": { + "name": "سلبي" + }, + "positive": { + "name": "إيجابي" + }, + "previous_frames": { + "name": "الإطارات السابقة" + }, + "start_image": { + "name": "صورة البداية" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "العرض" + } + }, + "outputs": { + "0": { + "name": "النموذج", + "tooltip": null + }, + "1": { + "name": "إيجابي", + "tooltip": null + }, + "2": { + "name": "سلبي", + "tooltip": null + }, + "3": { + "name": "الفضاء الكامن", + "tooltip": null + }, + "4": { + "name": "قص الصورة", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "مُرقّي فيديو سريع وعالي الجودة يعزز الدقة ويعيد الوضوح للمقاطع منخفضة الدقة أو الضبابية.", + "display_name": "ترقية فيديو FlashVSR", + "inputs": { + "target_resolution": { + "name": "الدقة المستهدفة" + }, + "video": { + "name": "الفيديو" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "عزز دقة وجودة الصورة، وارفع الصور إلى دقة 4K أو 8K للحصول على نتائج حادة ومفصلة.", + "display_name": "ترقية صورة WaveSpeed", + "inputs": { + "image": { + "name": "الصورة" + }, + "model": { + "name": "النموذج" + }, + "target_resolution": { + "name": "الدقة المستهدفة" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "التقاط كاميرا ويب", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "صورة" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "العرض" } diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index ff6e99066..20e285dbc 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "عرض صندوق أدوات التحديد" + "name": "عرض صندوق أدوات التحديد", + "tooltip": "عرض شريط أدوات عائم عند تحديد العقد، لتوفير وصول سريع إلى الإجراءات الشائعة." }, "Comfy_ConfirmClear": { "name": "طلب التأكيد عند مسح سير العمل" @@ -147,7 +148,8 @@ "Linear": "خطي", "Spline": "منحنى", "Straight": "مستقيم" - } + }, + "tooltip": "يتحكم في مظهر ووضوح الروابط بين العقد على اللوحة." }, "Comfy_Load3D_3DViewerEnable": { "name": "تمكين عارض ثلاثي الأبعاد (تجريبي)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "عرض العرض × الارتفاع تحت معاينة الصورة" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "عرض الأدوات المتقدمة دائمًا في جميع العقد", + "tooltip": "عند التفعيل، ستظهر الأدوات المتقدمة دائمًا في جميع العقد دون الحاجة لتوسيعها بشكل فردي." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "التثبيت التلقائي للرابط إلى فتحة العقدة", "tooltip": "عند سحب رابط فوق عقدة، يتم تثبيت الرابط تلقائيًا على فتحة إدخال صالحة في العقدة" @@ -332,6 +338,10 @@ "name": "حجم تاريخ قائمة الانتظار", "tooltip": "العدد الأقصى للمهام المعروضة في تاريخ قائمة الانتظار." }, + "Comfy_Queue_QPOV2": { + "name": "استخدم قائمة انتظار المهام الموحدة في لوحة الأصول الجانبية", + "tooltip": "يستبدل لوحة قائمة انتظار المهام العائمة بقائمة انتظار مهام مكافئة مدمجة في لوحة الأصول الجانبية. يمكنك تعطيل هذا الخيار للعودة إلى تخطيط اللوحة العائمة." + }, "Comfy_Sidebar_Location": { "name": "موقع الشريط الجانبي", "options": { @@ -466,6 +476,7 @@ "tooltip": "إزاحة نقطة تحكم بيزير من نقطة مركز إعادة التوجيه" }, "pysssss_SnapToGrid": { - "name": "الالتصاق بالشبكة دائمًا" + "name": "الالتصاق بالشبكة دائمًا", + "tooltip": "عند التفعيل، ستتم محاذاة العقد تلقائيًا إلى الشبكة عند تحريكها أو تغيير حجمها." } } diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index 60f906db0..c2c5eb6d2 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -345,4 +345,4 @@ "label": "Toggle Workflows Sidebar", "tooltip": "Workflows" } -} \ No newline at end of file +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 65189f5b8..1cbe43d10 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1,6 +1,7 @@ { "g": { "user": "User", + "you": "You", "currentUser": "Current user", "empty": "Empty", "noWorkflowsFound": "No workflows found.", @@ -43,8 +44,6 @@ "comfy": "Comfy", "refresh": "Refresh", "refreshNode": "Refresh Node", - "vitePreloadErrorTitle": "New Version Available", - "vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.", "terminal": "Terminal", "logs": "Logs", "videoFailedToLoad": "Video failed to load", @@ -88,6 +87,7 @@ "reportIssueTooltip": "Submit the error report to Comfy Org", "reportSent": "Report Submitted", "copyToClipboard": "Copy to Clipboard", + "copyAll": "Copy All", "openNewIssue": "Open New Issue", "showReport": "Show Report", "imageFailedToLoad": "Image failed to load", @@ -100,6 +100,11 @@ "no": "No", "cancel": "Cancel", "close": "Close", + "closeDialog": "Close dialog", + "showLeftPanel": "Show left panel", + "hideLeftPanel": "Hide left panel", + "showRightPanel": "Show right panel", + "hideRightPanel": "Hide right panel", "or": "or", "pressKeysForNewBinding": "Press keys for new binding", "defaultBanner": "default banner", @@ -158,6 +163,7 @@ "choose_file_to_upload": "choose file to upload", "capture": "capture", "nodes": "Nodes", + "nodesCount": "{count} nodes | {count} node | {count} nodes", "community": "Community", "all": "All", "versionMismatchWarning": "Version Compatibility Warning", @@ -178,6 +184,7 @@ "source": "Source", "filter": "Filter", "apply": "Apply", + "use": "Use", "enabled": "Enabled", "installed": "Installed", "restart": "Restart", @@ -269,11 +276,12 @@ "1x": "1x", "2x": "2x", "beta": "BETA", + "nightly": "NIGHTLY", "profile": "Profile", "noItems": "No items" }, "manager": { - "title": "Custom Nodes Manager", + "title": "Nodes Manager", "legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.", "legacyManagerUI": "Use Legacy UI", "legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui", @@ -702,6 +710,8 @@ "noImportedFiles": "No imported files found", "noGeneratedFiles": "No generated files found", "generatedAssetsHeader": "Generated assets", + "importedAssetsHeader": "Imported assets", + "activeJobStatus": "Active job: {status}", "noFilesFoundMessage": "Upload files or generate content to see them here", "browseTemplates": "Browse example templates", "openWorkflow": "Open workflow in local file system", @@ -751,6 +761,7 @@ "sortJobs": "Sort jobs", "sortBy": "Sort by", "activeJobs": "{count} active job | {count} active jobs", + "activeJobsShort": "{count} active | {count} active", "activeJobsSuffix": "active jobs", "jobQueue": "Job Queue", "expandCollapsedQueue": "Expand job queue", @@ -1275,7 +1286,9 @@ "VueNodes": "Nodes 2.0", "Nodes 2_0": "Nodes 2.0", "Execution": "Execution", - "PLY": "PLY" + "PLY": "PLY", + "Workspace": "Workspace", + "Other": "Other" }, "serverConfigItems": { "listen": { @@ -1426,6 +1439,7 @@ "latent": "latent", "mask": "mask", "api node": "api node", + "Bria": "Bria", "video": "video", "ByteDance": "ByteDance", "preprocessors": "preprocessors", @@ -1476,6 +1490,7 @@ "lotus": "lotus", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "model_specific": "model_specific", @@ -1499,6 +1514,7 @@ "stable_cascade": "stable_cascade", "3d_models": "3d_models", "style_model": "style_model", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", @@ -1506,6 +1522,7 @@ "": "", "camera": "camera", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "zimage": "zimage" }, "dataTypes": { @@ -1718,10 +1735,22 @@ "unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)", "uploadingModel": "Uploading 3D model..." }, + "imageCrop": { + "loading": "Loading...", + "noInputImage": "No input image connected", + "cropPreviewAlt": "Crop preview" + }, + "boundingBox": { + "x": "X", + "y": "Y", + "width": "Width", + "height": "Height" + }, "toastMessages": { "nothingToQueue": "Nothing to queue", "pleaseSelectOutputNodes": "Please select output nodes", "failedToQueue": "Failed to queue", + "failedToSaveDraft": "Failed to save workflow draft", "failedExecutionPathResolution": "Could not resolve path to selected nodes", "no3dScene": "No 3D scene to apply texture", "failedToApplyTexture": "Failed to apply texture", @@ -1986,6 +2015,8 @@ "renewsDate": "Renews {date}", "expiresDate": "Expires {date}", "manageSubscription": "Manage subscription", + "managePayment": "Manage Payment", + "cancelSubscription": "Cancel Subscription", "partnerNodesBalance": "\"Partner Nodes\" Credit Balance", "partnerNodesDescription": "For running commercial/proprietary models", "totalCredits": "Total credits", @@ -2040,6 +2071,9 @@ "subscribeToRunFull": "Subscribe to Run", "subscribeNow": "Subscribe Now", "subscribeToComfyCloud": "Subscribe to Comfy Cloud", + "workspaceNotSubscribed": "This workspace is not on a subscription", + "subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud", + "contactOwnerToSubscribe": "Contact the workspace owner to subscribe", "description": "Choose the best plan for you", "haveQuestions": "Have questions or wondering about enterprise?", "contactUs": "Contact us", @@ -2070,17 +2104,144 @@ "creator": "30 min", "pro": "1 hr", "founder": "30 min" + }, + "billingComingSoon": { + "title": "Coming Soon", + "message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates." } }, "userSettings": { "title": "My Account Settings", "accountSettings": "Account settings", + "workspaceSettings": "Workspace settings", "name": "Name", "email": "Email", "provider": "Sign-in Provider", "notSet": "Not set", "updatePassword": "Update Password" }, + "workspacePanel": { + "invite": "Invite", + "inviteMember": "Invite member", + "inviteLimitReached": "You've reached the maximum of 50 members", + "tabs": { + "dashboard": "Dashboard", + "planCredits": "Plan & Credits", + "membersCount": "Members ({count})" + }, + "dashboard": { + "placeholder": "Dashboard workspace settings" + }, + "members": { + "membersCount": "{count}/50 Members", + "pendingInvitesCount": "{count} pending invite | {count} pending invites", + "tabs": { + "active": "Active", + "pendingCount": "Pending ({count})" + }, + "columns": { + "inviteDate": "Invite date", + "expiryDate": "Expiry date", + "joinDate": "Join date" + }, + "actions": { + "copyLink": "Copy invite link", + "revokeInvite": "Revoke invite", + "removeMember": "Remove member" + }, + "noInvites": "No pending invites", + "noMembers": "No members", + "personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,", + "createNewWorkspace": "create a new one." + }, + "menu": { + "editWorkspace": "Edit workspace details", + "leaveWorkspace": "Leave Workspace", + "deleteWorkspace": "Delete Workspace", + "deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first" + }, + "editWorkspaceDialog": { + "title": "Edit workspace details", + "nameLabel": "Workspace name", + "save": "Save" + }, + "leaveDialog": { + "title": "Leave this workspace?", + "message": "You won't be able to join again unless you contact the workspace owner.", + "leave": "Leave" + }, + "deleteDialog": { + "title": "Delete this workspace?", + "message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.", + "messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone." + }, + "removeMemberDialog": { + "title": "Remove this member?", + "message": "This member will be removed from your workspace. Credits they've used will not be refunded.", + "remove": "Remove member", + "success": "Member removed", + "error": "Failed to remove member" + }, + "revokeInviteDialog": { + "title": "Uninvite this person?", + "message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.", + "revoke": "Uninvite" + }, + "inviteMemberDialog": { + "title": "Invite a person to this workspace", + "message": "Create a shareable invite link to send to someone", + "placeholder": "Enter the person's email", + "createLink": "Create link", + "linkStep": { + "title": "Send this link to the person", + "message": "Make sure their account uses this email.", + "copyLink": "Copy Link", + "done": "Done" + }, + "linkCopied": "Copied", + "linkCopyFailed": "Failed to copy link" + }, + "createWorkspaceDialog": { + "title": "Create a new workspace", + "message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.", + "nameLabel": "Workspace name*", + "namePlaceholder": "Enter workspace name", + "create": "Create" + }, + "toast": { + "workspaceCreated": { + "title": "Workspace created", + "message": "Subscribe to a plan, invite teammates, and start collaborating.", + "subscribe": "Subscribe" + }, + "workspaceUpdated": { + "title": "Workspace updated", + "message": "Workspace details have been saved." + }, + "workspaceDeleted": { + "title": "Workspace deleted", + "message": "The workspace has been permanently deleted." + }, + "workspaceLeft": { + "title": "Left workspace", + "message": "You have left the workspace." + }, + "failedToUpdateWorkspace": "Failed to update workspace", + "failedToCreateWorkspace": "Failed to create workspace", + "failedToDeleteWorkspace": "Failed to delete workspace", + "failedToLeaveWorkspace": "Failed to leave workspace", + "failedToFetchWorkspaces": "Failed to load workspaces" + } + }, + "workspaceSwitcher": { + "switchWorkspace": "Switch workspace", + "subscribe": "Subscribe", + "personal": "Personal", + "roleOwner": "Owner", + "roleMember": "Member", + "createWorkspace": "Create new workspace", + "maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one." + }, "selectionToolbox": { "executeButton": { "tooltip": "Execute to selected output nodes (Highlighted with orange border)", @@ -2295,6 +2456,13 @@ "assetBrowser": { "allCategory": "All {category}", "allModels": "All Models", + "byType": "By type", + "emptyImported": { + "canImport": "No imported models yet. Click \"Import\" to add your own.", + "restricted": "Personal models are only available at Creator tier and above." + }, + "noResultsCanImport": "Try adjusting your search or filters.\nYou can also add models using the \"Import\" button above.", + "imported": "Imported", "assetCollection": "Asset collection", "assets": "Assets", "baseModels": "Base models", @@ -2385,6 +2553,30 @@ "assetCard": "{name} - {type} asset", "loadingAsset": "Loading asset" }, + "modelInfo": { + "title": "Model Info", + "selectModelPrompt": "Select a model to see its information", + "basicInfo": "Basic Info", + "displayName": "Display Name", + "editDisplayName": "Edit display name", + "fileName": "File Name", + "source": "Source", + "viewOnSource": "View on {source}", + "modelTagging": "Model Tagging", + "modelType": "Model Type", + "selectModelType": "Select model type...", + "compatibleBaseModels": "Compatible Base Models", + "addBaseModel": "Add base model...", + "baseModelUnknown": "Base model unknown", + "additionalTags": "Additional Tags", + "addTag": "Add tag...", + "noAdditionalTags": "No additional tags", + "modelDescription": "Model Description", + "triggerPhrases": "Trigger Phrases", + "description": "Description", + "descriptionNotSet": "No description set", + "descriptionPlaceholder": "Add a description for this model..." + }, "media": { "threeDModelPlaceholder": "3D Model", "audioPlaceholder": "Audio" @@ -2496,7 +2688,7 @@ }, "linearMode": { "linearMode": "Simple Mode", - "beta": "Beta - Give Feedback", + "beta": "Simple Mode in Beta - Feedback", "graphMode": "Graph Mode", "dragAndDropImage": "Click to browse or drag an image", "runCount": "Run count:", @@ -2571,7 +2763,8 @@ "noneSearchDesc": "No items match your search", "nodesNoneDesc": "NO NODES", "fallbackGroupTitle": "Group", - "fallbackNodeTitle": "Node" + "fallbackNodeTitle": "Node", + "hideAdvancedInputsButton": "Hide advanced inputs" }, "help": { "recentReleases": "Recent releases", @@ -2597,7 +2790,10 @@ "unsavedChanges": { "title": "Unsaved Changes", "message": "You have unsaved changes. Do you want to discard them and switch workspaces?" - } + }, + "inviteAccepted": "Invite Accepted", + "addedToWorkspace": "You have been added to {workspaceName}", + "inviteFailed": "Failed to Accept Invite" }, "workspaceAuth": { "errors": { @@ -2607,5 +2803,11 @@ "workspaceNotFound": "Workspace not found", "tokenExchangeFailed": "Failed to authenticate with workspace: {error}" } + }, + "nightly": { + "badge": { + "label": "Preview Version", + "tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features." + } } -} \ No newline at end of file +} diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index a304444ed..0e986687f 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "display_name": "Bria FIBO Image Edit", + "description": "Edit images using Bria latest model", + "inputs": { + "model": { + "name": "model" + }, + "image": { + "name": "image" + }, + "prompt": { + "name": "prompt", + "tooltip": "Instruction to edit image" + }, + "negative_prompt": { + "name": "negative_prompt" + }, + "structured_prompt": { + "name": "structured_prompt", + "tooltip": "A string containing the structured edit prompt in JSON format. Use this instead of usual prompt for precise, programmatic control." + }, + "seed": { + "name": "seed" + }, + "guidance_scale": { + "name": "guidance_scale", + "tooltip": "Higher value makes the image follow the prompt more closely." + }, + "steps": { + "name": "steps" + }, + "moderation": { + "name": "moderation", + "tooltip": "Moderation settings" + }, + "mask": { + "name": "mask", + "tooltip": "If omitted, the edit applies to the entire image." + }, + "control_after_generate": { + "name": "control after generate" + }, + "moderation_prompt_content_moderation": { + "name": "prompt_content_moderation" + }, + "moderation_visual_input_moderation": { + "name": "visual_input_moderation" + }, + "moderation_visual_output_moderation": { + "name": "visual_output_moderation" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "structured_prompt", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "display_name": "ByteDance First-Last-Frame to Video", "description": "Generate video using prompt and first and last frames.", @@ -371,42 +433,9 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, - "control_after_generate": { - "name": "control after generate" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, - "ByteDanceImageEditNode": { - "display_name": "ByteDance Image Edit", - "description": "Edit images using ByteDance models via api based on prompt", - "inputs": { - "model": { - "name": "model" - }, - "image": { - "name": "image", - "tooltip": "The base image to edit" - }, - "prompt": { - "name": "prompt", - "tooltip": "Instruction to edit image" - }, - "seed": { - "name": "seed", - "tooltip": "Seed to use for generation" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "Higher value makes the image follow the prompt more closely" - }, - "watermark": { - "name": "watermark", - "tooltip": "Whether to add an \"AI generated\" watermark to the image" + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." }, "control_after_generate": { "name": "control after generate" @@ -547,6 +576,10 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + }, "control_after_generate": { "name": "control after generate" } @@ -650,6 +683,10 @@ "name": "watermark", "tooltip": "Whether to add an \"AI generated\" watermark to the video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + }, "control_after_generate": { "name": "control after generate" } @@ -2021,11 +2058,16 @@ "choice": { "name": "choice" }, - "option0": {} + "index": {}, + "option1": {} }, "outputs": { "0": { "tooltip": null + }, + "1": { + "name": "INDEX", + "tooltip": null } } }, @@ -5989,7 +6031,7 @@ } }, "LoraLoader": { - "display_name": "Load LoRA", + "display_name": "Load LoRA (Model and CLIP)", "description": "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", "inputs": { "model": { @@ -6022,8 +6064,62 @@ } } }, + "LoraLoaderBypass": { + "display_name": "Load LoRA (Bypass) (For debugging)", + "description": "Apply LoRA in bypass mode. Unlike regular LoRA, this doesn't modify model weights - instead it injects the LoRA computation during forward pass. Useful for training scenarios.", + "inputs": { + "model": { + "name": "model", + "tooltip": "The diffusion model the LoRA will be applied to." + }, + "clip": { + "name": "clip", + "tooltip": "The CLIP model the LoRA will be applied to." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "The name of the LoRA." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "How strongly to modify the diffusion model. This value can be negative." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "How strongly to modify the CLIP model. This value can be negative." + } + }, + "outputs": { + "0": { + "tooltip": "The modified diffusion model." + }, + "1": { + "tooltip": "The modified CLIP model." + } + } + }, + "LoraLoaderBypassModelOnly": { + "display_name": "Load LoRA (Bypass, Model Only) (for debugging)", + "description": "Apply LoRA in bypass mode. Unlike regular LoRA, this doesn't modify model weights - instead it injects the LoRA computation during forward pass. Useful for training scenarios.", + "inputs": { + "model": { + "name": "model" + }, + "lora_name": { + "name": "lora_name" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "The modified diffusion model." + } + } + }, "LoraLoaderModelOnly": { - "display_name": "LoraLoaderModelOnly", + "display_name": "Load LoRA", "description": "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", "inputs": { "model": { @@ -6755,6 +6851,126 @@ } } }, + "MagnificImageRelightNode": { + "display_name": "Magnific Image Relight", + "description": "Relight an image with lighting adjustments and optional reference-based light transfer.", + "inputs": { + "image": { + "name": "image", + "tooltip": "The image to relight." + }, + "prompt": { + "name": "prompt", + "tooltip": "Descriptive guidance for lighting. Supports emphasis notation (1-1.4)." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Intensity of light transfer application." + }, + "style": { + "name": "style", + "tooltip": "Stylistic output preference." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Restricts generation freedom to match original more closely." + }, + "change_background": { + "name": "change_background", + "tooltip": "Modifies background based on prompt/reference." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Maintains texture and fine details from original." + }, + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Fine-tuning options for advanced lighting control." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Optional reference image to transfer lighting from." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "display_name": "Magnific Image Skin Enhancer", + "description": "Skin enhancement for portraits with multiple processing modes.", + "inputs": { + "image": { + "name": "image", + "tooltip": "The portrait image to enhance." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Sharpening intensity level." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Smart grain intensity level." + }, + "mode": { + "name": "mode", + "tooltip": "Processing mode: creative for artistic enhancement, faithful for preserving original appearance, flexible for targeted optimization." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "display_name": "Magnific Image Style Transfer", + "description": "Transfer the style from a reference image to your input image.", + "inputs": { + "image": { + "name": "image", + "tooltip": "The image to apply style transfer to." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "The reference image to extract style from." + }, + "prompt": { + "name": "prompt" + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Percentage of style strength." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Maintains the structure of the original image." + }, + "flavor": { + "name": "flavor", + "tooltip": "Style transfer flavor." + }, + "engine": { + "name": "engine", + "tooltip": "Processing engine selection." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Enable portrait mode for facial enhancements." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "When disabled, expect each generation to introduce a degree of randomness, leading to more diverse outcomes." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "display_name": "Mahiro CFG", "description": "Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.", @@ -9783,8 +9999,8 @@ } }, "OpenAIGPTImage1": { - "display_name": "OpenAI GPT Image 1", - "description": "Generates images synchronously via OpenAI's GPT Image 1 endpoint.", + "display_name": "OpenAI GPT Image 1.5", + "description": "Generates images synchronously via OpenAI's GPT Image endpoint.", "inputs": { "prompt": { "name": "prompt", @@ -11206,18 +11422,27 @@ }, "ResizeImageMaskNode": { "display_name": "Resize Image/Mask", + "description": "Resize an image or mask using various scaling methods.", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "Select how to resize: by exact dimensions, scale factor, matching another image, etc." }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "Interpolation algorithm. 'area' is best for downscaling, 'lanczos' for upscaling, 'nearest-exact' for pixel art." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "crop" + }, + "resize_type_height": { + "name": "height" + }, + "resize_type_width": { + "name": "width" } }, "outputs": { @@ -13362,6 +13587,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: Image(s) to Model (Pro)", + "inputs": { + "model": { + "name": "model", + "tooltip": "The LowPoly option is unavailable for the `3.1` model." + }, + "image": { + "name": "image" + }, + "face_count": { + "name": "face_count" + }, + "generate_type": { + "name": "generate_type" + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "image_left": { + "name": "image_left" + }, + "image_right": { + "name": "image_right" + }, + "image_back": { + "name": "image_back" + }, + "control_after_generate": { + "name": "control after generate" + }, + "generate_type_pbr": { + "name": "pbr" + } + }, + "outputs": { + "0": { + "name": "model_file", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: Text to Model (Pro)", + "inputs": { + "model": { + "name": "model", + "tooltip": "The LowPoly option is unavailable for the `3.1` model." + }, + "prompt": { + "name": "prompt", + "tooltip": "Supports up to 1024 characters." + }, + "face_count": { + "name": "face_count" + }, + "generate_type": { + "name": "generate_type" + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "control_after_generate": { + "name": "control after generate" + }, + "generate_type_pbr": { + "name": "pbr" + } + }, + "outputs": { + "0": { + "name": "model_file", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13457,6 +13760,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "clip": { + "name": "clip" + }, + "prompt": { + "name": "prompt" + }, + "auto_resize_images": { + "name": "auto_resize_images" + }, + "image_encoder": { + "name": "image_encoder" + }, + "vae": { + "name": "vae" + }, + "image1": { + "name": "image1" + }, + "image2": { + "name": "image2" + }, + "image3": { + "name": "image3" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Text to Lowercase", "inputs": { @@ -13718,24 +14055,24 @@ "name": "bucket_mode", "tooltip": "Enable resolution bucket mode. When enabled, expects pre-bucketed latents from ResolutionBucket node." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Enable bypass mode for training. When enabled, adapters are applied via forward hooks instead of weight modification. Useful for quantized models where weights cannot be directly modified." + }, "control_after_generate": { "name": "control after generate" } }, "outputs": { "0": { - "name": "model", - "tooltip": "Model with LoRA applied" - }, - "1": { "name": "lora", "tooltip": "LoRA weights" }, - "2": { + "1": { "name": "loss_map", "tooltip": "Loss history" }, - "3": { + "2": { "name": "steps", "tooltip": "Total training steps" } @@ -15671,6 +16008,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "mode": { + "name": "mode" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "positive": { + "name": "positive" + }, + "negative": { + "name": "negative" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "width" + }, + "height": { + "name": "height" + }, + "length": { + "name": "length" + }, + "audio_encoder_output_1": { + "name": "audio_encoder_output_1" + }, + "motion_frame_count": { + "name": "motion_frame_count", + "tooltip": "Number of previous frames to use as motion context." + }, + "audio_scale": { + "name": "audio_scale" + }, + "clip_vision_output": { + "name": "clip_vision_output" + }, + "start_image": { + "name": "start_image" + }, + "previous_frames": { + "name": "previous_frames" + } + }, + "outputs": { + "0": { + "name": "model", + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "trim_image", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16187,6 +16597,43 @@ } } }, + "WavespeedFlashVSRNode": { + "display_name": "FlashVSR Video Upscale", + "description": "Fast, high-quality video upscaler that boosts resolution and restores clarity for low-resolution or blurry footage.", + "inputs": { + "video": { + "name": "video" + }, + "target_resolution": { + "name": "target_resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "display_name": "WaveSpeed Image Upscale", + "description": "Boost image resolution and quality, upscaling photos to 4K or 8K for sharp, detailed results.", + "inputs": { + "model": { + "name": "model" + }, + "image": { + "name": "image" + }, + "target_resolution": { + "name": "target_resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Webcam Capture", "inputs": { @@ -16231,4 +16678,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 3fdd3dfa7..bab1856e8 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Show selection toolbox" + "name": "Show selection toolbox", + "tooltip": "Display a floating toolbar when nodes are selected, providing quick access to common actions." }, "Comfy_ConfirmClear": { "name": "Require confirmation when clearing workflow" @@ -142,6 +143,7 @@ }, "Comfy_LinkRenderMode": { "name": "Link Render Mode", + "tooltip": "Controls the appearance and visibility of connection links between nodes on the canvas.", "options": { "Straight": "Straight", "Linear": "Linear", @@ -220,6 +222,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Show width × height below the image preview" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Always show advanced widgets on all nodes", + "tooltip": "When enabled, advanced widgets are always visible on all nodes without needing to expand them individually." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Auto snap link to node slot", "tooltip": "When dragging a link over a node, the link automatically snap to a viable input slot on the node" @@ -328,6 +334,10 @@ "name": "Queue history size", "tooltip": "The maximum number of tasks that show in the queue history." }, + "Comfy_Queue_QPOV2": { + "name": "Use the unified job queue in the Assets side panel", + "tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout." + }, "Comfy_QueueButton_BatchCountLimit": { "name": "Batch count limit", "tooltip": "The maximum number of tasks added to the queue at one button click" @@ -466,6 +476,7 @@ "tooltip": "The bezier control point offset from the reroute centre point" }, "pysssss_SnapToGrid": { - "name": "Always snap to grid" + "name": "Always snap to grid", + "tooltip": "When enabled, nodes will automatically align to the grid when moved or resized." } -} \ No newline at end of file +} diff --git a/src/locales/es/main.json b/src/locales/es/main.json index cd59b5bf8..7ac9b7de1 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -24,6 +24,7 @@ "assets": "Recursos", "baseModels": "Modelos base", "browseAssets": "Explorar recursos", + "byType": "Por tipo", "checkpoints": "Checkpoints", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "Ejemplo:", @@ -45,6 +46,10 @@ "failed": "La descarga falló", "inProgress": "Descargando {assetName}..." }, + "emptyImported": { + "canImport": "Aún no hay modelos importados. Haz clic en \"Importar modelo\" para añadir el tuyo.", + "restricted": "Los modelos personales solo están disponibles en el nivel Creador o superior." + }, "errorFileTooLarge": "El archivo excede el tamaño máximo permitido", "errorFormatNotAllowed": "Solo se permite el formato SafeTensor", "errorModelTypeNotSupported": "Este tipo de modelo no es compatible", @@ -61,6 +66,7 @@ "finish": "Finalizar", "genericLinkPlaceholder": "Pega el enlace aquí", "importAnother": "Importar otro", + "imported": "Importado", "jobId": "ID de tarea", "loadingModels": "Cargando {type}...", "maxFileSize": "Tamaño máximo de archivo: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "Modelo 3D" }, "modelAssociatedWithLink": "El modelo asociado con el enlace que proporcionaste:", + "modelInfo": { + "addBaseModel": "Agregar modelo base...", + "addTag": "Agregar etiqueta...", + "additionalTags": "Etiquetas adicionales", + "baseModelUnknown": "Modelo base desconocido", + "basicInfo": "Información básica", + "compatibleBaseModels": "Modelos base compatibles", + "description": "Descripción", + "descriptionNotSet": "Sin descripción", + "descriptionPlaceholder": "Agrega una descripción para este modelo...", + "displayName": "Nombre para mostrar", + "editDisplayName": "Editar nombre para mostrar", + "fileName": "Nombre de archivo", + "modelDescription": "Descripción del modelo", + "modelTagging": "Etiquetado del modelo", + "modelType": "Tipo de modelo", + "noAdditionalTags": "Sin etiquetas adicionales", + "selectModelPrompt": "Selecciona un modelo para ver su información", + "selectModelType": "Selecciona el tipo de modelo...", + "source": "Fuente", + "title": "Información del modelo", + "triggerPhrases": "Frases de activación", + "viewOnSource": "Ver en {source}" + }, "modelName": "Nombre del modelo", "modelNamePlaceholder": "Introduce un nombre para este modelo", "modelTypeSelectorLabel": "¿Qué tipo de modelo es este?", @@ -238,6 +268,12 @@ "title": "Crea una cuenta" } }, + "boundingBox": { + "height": "Alto", + "width": "Ancho", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Limpiar flujo de trabajo", "deleteBlueprint": "Eliminar Plano", @@ -678,6 +714,7 @@ "clearAll": "Borrar todo", "clearFilters": "Borrar filtros", "close": "Cerrar", + "closeDialog": "Cerrar diálogo", "color": "Color", "comfy": "Comfy", "comfyOrgLogoAlt": "Logo de ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "control antes de generar", "copied": "Copiado", "copy": "Copiar", + "copyAll": "Copiar todo", "copyJobId": "Copiar ID de trabajo", "copyToClipboard": "Copiar al portapapeles", "copyURL": "Copiar URL", @@ -756,6 +794,8 @@ "goToNode": "Ir al nodo", "graphNavigation": "Navegación de gráficos", "halfSpeed": "0.5x", + "hideLeftPanel": "Ocultar panel izquierdo", + "hideRightPanel": "Ocultar panel derecho", "icon": "Icono", "imageFailedToLoad": "Falló la carga de la imagen", "imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes", @@ -797,6 +837,7 @@ "name": "Nombre", "newFolder": "Nueva carpeta", "next": "Siguiente", + "nightly": "NIGHTLY", "no": "No", "noAudioRecorded": "No se grabó audio", "noItems": "Sin elementos", @@ -811,6 +852,7 @@ "nodeSlotsError": "Error de Ranuras del Nodo", "nodeWidgetsError": "Error de Widgets del Nodo", "nodes": "Nodos", + "nodesCount": "{count} nodos | {count} nodo | {count} nodos", "nodesRunning": "nodos en ejecución", "none": "Ninguno", "nothingToCopy": "Nada para copiar", @@ -885,7 +927,9 @@ "selectedFile": "Archivo seleccionado", "setAsBackground": "Establecer como fondo", "settings": "Configuraciones", + "showLeftPanel": "Mostrar panel izquierdo", "showReport": "Mostrar informe", + "showRightPanel": "Mostrar panel derecho", "singleSelectDropdown": "Menú desplegable de selección única", "sort": "Ordenar", "source": "Fuente", @@ -908,6 +952,7 @@ "updating": "Actualizando", "upload": "Subir", "usageHint": "Sugerencia de uso", + "use": "Usar", "user": "Usuario", "versionMismatchWarning": "Advertencia de compatibilidad de versión", "versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.", @@ -915,11 +960,10 @@ "videoPreview": "Vista previa de video - Usa las teclas de flecha para navegar entre videos", "viewImageOfTotal": "Ver imagen {index} de {total}", "viewVideoOfTotal": "Ver video {index} de {total}", - "vitePreloadErrorMessage": "Se ha lanzado una nueva versión de la aplicación. ¿Deseas recargar?\nSi no lo haces, algunas partes de la aplicación podrían no funcionar correctamente.\nPuedes rechazar y guardar tu progreso antes de recargar.", - "vitePreloadErrorTitle": "Nueva versión disponible", "volume": "Volumen", "warning": "Advertencia", - "workflow": "Flujo de trabajo" + "workflow": "Flujo de trabajo", + "you": "Tú" }, "graphCanvasMenu": { "fitView": "Ajustar vista", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "No hay imágenes para comparar" }, + "imageCrop": { + "cropPreviewAlt": "Vista previa del recorte", + "loading": "Cargando...", + "noInputImage": "No hay imagen de entrada conectada" + }, "importFailed": { "copyError": "Error al copiar", "title": "Error de importación" @@ -1606,17 +1655,25 @@ "title": "Este flujo de trabajo tiene nodos faltantes" } }, + "nightly": { + "badge": { + "label": "Versión preliminar", + "tooltip": "Estás usando una versión nightly de ComfyUI. Por favor, utiliza el botón de comentarios para compartir tus opiniones sobre estas funciones." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "modelos_3d", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_para_pruebas", "advanced": "avanzado", "animation": "animación", @@ -1841,6 +1900,7 @@ }, "groupSettings": "Configuración de grupo", "groups": "Grupos", + "hideAdvancedInputsButton": "Ocultar entradas avanzadas", "hideInput": "Ocultar entrada", "info": "Información", "inputs": "ENTRADAS", @@ -2073,6 +2133,7 @@ "NodeLibrary": "Biblioteca de Nodos", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "Preferencias de notificación", + "Other": "Otros", "PLY": "PLY", "PlanCredits": "Plan y créditos", "Pointer": "Puntero", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Nodos Vue", "VueNodes": "Nodos Vue", "Window": "Ventana", - "Workflow": "Flujo de Trabajo" + "Workflow": "Flujo de Trabajo", + "Workspace": "Espacio de trabajo" }, "shape": { "CARD": "Card", @@ -2118,12 +2180,14 @@ "viewControls": "Controles de vista" }, "sideToolbar": { + "activeJobStatus": "Trabajo activo: {status}", "assets": "Recursos", "backToAssets": "Volver a todos los recursos", "browseTemplates": "Explorar plantillas de ejemplo", "downloads": "Descargas", "generatedAssetsHeader": "Recursos generados", "helpCenter": "Centro de ayuda", + "importedAssetsHeader": "Recursos importados", "labels": { "assets": "Recursos", "console": "Consola", @@ -2168,6 +2232,7 @@ "queue": "Cola", "queueProgressOverlay": { "activeJobs": "{count} trabajo activo | {count} trabajos activos", + "activeJobsShort": "{count} activo(s) | {count} activo(s)", "activeJobsSuffix": "trabajos activos", "cancelJobTooltip": "Cancelar trabajo", "clearHistory": "Limpiar historial de la cola de trabajos", @@ -2256,9 +2321,15 @@ "beta": "BETA", "billedMonthly": "Facturado mensualmente", "billedYearly": "{total} facturado anualmente", + "billingComingSoon": { + "message": "La facturación para equipos estará disponible pronto. Podrás suscribirte a un plan para tu espacio de trabajo con precios por usuario. Mantente atento para más actualizaciones.", + "title": "Próximamente" + }, + "cancelSubscription": "Cancelar suscripción", "changeTo": "Cambiar a {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo de Comfy Cloud", + "contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte", "contactUs": "Contáctanos", "creditsRemainingThisMonth": "Créditos restantes este mes", "creditsRemainingThisYear": "Créditos restantes este año", @@ -2271,6 +2342,7 @@ "haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?", "invoiceHistory": "Historial de facturas", "learnMore": "Más información", + "managePayment": "Gestionar pago", "managePlan": "Gestionar plan", "manageSubscription": "Gestionar suscripción", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "Suscribirse a Comfy Cloud", "subscribeToRun": "Suscribirse", "subscribeToRunFull": "Suscribirse a Ejecutar", + "subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube", "tierNameYearly": "{name} Anual", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "Ver más detalles", "viewMoreDetailsPlans": "Ver más detalles sobre planes y precios", "viewUsageHistory": "Ver historial de uso", + "workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción", "yearly": "Anual", "yearlyCreditsLabel": "Total de créditos anuales", "yearlyDiscount": "20% DESCUENTO", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "Error al cargar el modelo 3D", "failedToPurchaseCredits": "No se pudo comprar créditos: {error}", "failedToQueue": "Error al encolar", + "failedToSaveDraft": "No se pudo guardar el borrador del flujo de trabajo", "failedToToggleCamera": "No se pudo alternar la cámara", "failedToToggleGrid": "No se pudo alternar la cuadrícula", "failedToUpdateBackgroundColor": "No se pudo actualizar el color de fondo", @@ -2486,7 +2561,8 @@ "notSet": "No establecido", "provider": "Método de inicio de sesión", "title": "Configuración de usuario", - "updatePassword": "Actualizar contraseña" + "updatePassword": "Actualizar contraseña", + "workspaceSettings": "Configuración del espacio de trabajo" }, "validation": { "descriptionRequired": "Descripción es requerida", @@ -2577,6 +2653,9 @@ "saveWorkflow": "Guardar flujo de trabajo" }, "workspace": { + "addedToWorkspace": "Has sido añadido a {workspaceName}", + "inviteAccepted": "Invitación aceptada", + "inviteFailed": "No se pudo aceptar la invitación", "unsavedChanges": { "message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?", "title": "Cambios no guardados" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "Espacio de trabajo no encontrado" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "Crear", + "message": "Los espacios de trabajo permiten a los miembros compartir un único fondo de créditos. Te convertirás en el propietario después de crearlo.", + "nameLabel": "Nombre del espacio de trabajo*", + "namePlaceholder": "Introduce el nombre del espacio de trabajo", + "title": "Crear un nuevo espacio de trabajo" + }, + "dashboard": { + "placeholder": "Configuración del panel del espacio de trabajo" + }, + "deleteDialog": { + "message": "Cualquier crédito no utilizado o recurso no guardado se perderá. Esta acción no se puede deshacer.", + "messageWithName": "¿Eliminar \"{name}\"? Cualquier crédito no utilizado o recurso no guardado se perderá. Esta acción no se puede deshacer.", + "title": "¿Eliminar este espacio de trabajo?" + }, + "editWorkspaceDialog": { + "nameLabel": "Nombre del espacio de trabajo", + "save": "Guardar", + "title": "Editar detalles del espacio de trabajo" + }, + "invite": "Invitar", + "inviteLimitReached": "Has alcanzado el máximo de 50 miembros", + "inviteMember": "Invitar miembro", + "inviteMemberDialog": { + "createLink": "Crear enlace", + "linkCopied": "Copiado", + "linkCopyFailed": "No se pudo copiar el enlace", + "linkStep": { + "copyLink": "Copiar enlace", + "done": "Listo", + "message": "Asegúrate de que su cuenta use este correo electrónico.", + "title": "Envía este enlace a la persona" + }, + "message": "Crea un enlace de invitación para compartir y envíalo a alguien", + "placeholder": "Introduce el correo electrónico de la persona", + "title": "Invitar a una persona a este espacio de trabajo" + }, + "leaveDialog": { + "leave": "Abandonar", + "message": "No podrás unirte de nuevo a menos que contactes al propietario del espacio de trabajo.", + "title": "¿Abandonar este espacio de trabajo?" + }, + "members": { + "actions": { + "copyLink": "Copiar enlace de invitación", + "removeMember": "Eliminar miembro", + "revokeInvite": "Revocar invitación" + }, + "columns": { + "expiryDate": "Fecha de vencimiento", + "inviteDate": "Fecha de invitación", + "joinDate": "Fecha de ingreso" + }, + "createNewWorkspace": "crea uno nuevo.", + "membersCount": "{count}/50 Miembros", + "noInvites": "No hay invitaciones pendientes", + "noMembers": "No hay miembros", + "pendingInvitesCount": "{count} invitación pendiente | {count} invitaciones pendientes", + "personalWorkspaceMessage": "No puedes invitar a otros miembros a tu espacio de trabajo personal en este momento. Para agregar miembros a un espacio de trabajo,", + "tabs": { + "active": "Activo", + "pendingCount": "Pendientes ({count})" + } + }, + "menu": { + "deleteWorkspace": "Eliminar espacio de trabajo", + "deleteWorkspaceDisabledTooltip": "Primero cancela la suscripción activa de tu espacio de trabajo", + "editWorkspace": "Editar detalles del espacio de trabajo", + "leaveWorkspace": "Abandonar espacio de trabajo" + }, + "removeMemberDialog": { + "error": "No se pudo eliminar al miembro", + "message": "Este miembro será eliminado de tu espacio de trabajo. Los créditos que haya utilizado no serán reembolsados.", + "remove": "Eliminar miembro", + "success": "Miembro eliminado", + "title": "¿Eliminar a este miembro?" + }, + "revokeInviteDialog": { + "message": "Este miembro ya no podrá unirse a tu espacio de trabajo. Su enlace de invitación será invalidado.", + "revoke": "Desinvitar", + "title": "¿Desinvitar a esta persona?" + }, + "tabs": { + "dashboard": "Panel", + "membersCount": "Miembros ({count})", + "planCredits": "Plan y créditos" + }, + "toast": { + "failedToCreateWorkspace": "No se pudo crear el espacio de trabajo", + "failedToDeleteWorkspace": "No se pudo eliminar el espacio de trabajo", + "failedToFetchWorkspaces": "No se pudieron cargar los espacios de trabajo", + "failedToLeaveWorkspace": "No se pudo abandonar el espacio de trabajo", + "failedToUpdateWorkspace": "No se pudo actualizar el espacio de trabajo", + "workspaceCreated": { + "message": "Suscríbete a un plan, invita a compañeros y comienza a colaborar.", + "subscribe": "Suscribirse", + "title": "Espacio de trabajo creado" + }, + "workspaceDeleted": { + "message": "El espacio de trabajo ha sido eliminado permanentemente.", + "title": "Espacio de trabajo eliminado" + }, + "workspaceLeft": { + "message": "Has salido del espacio de trabajo.", + "title": "Saliste del espacio de trabajo" + }, + "workspaceUpdated": { + "message": "Los detalles del espacio de trabajo se han guardado.", + "title": "Espacio de trabajo actualizado" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "Crear nuevo espacio de trabajo", + "maxWorkspacesReached": "Solo puedes ser propietario de 10 espacios de trabajo. Elimina uno para crear uno nuevo.", + "personal": "Personal", + "roleMember": "Miembro", + "roleOwner": "Propietario", + "subscribe": "Suscribirse", + "switchWorkspace": "Cambiar espacio de trabajo" + }, "zoomControls": { "hideMinimap": "Ocultar minimapa", "label": "Controles de zoom", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 78ee2246d..98729c6f3 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Edita imágenes usando el modelo más reciente de Bria", + "display_name": "Edición de Imagen Bria", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "guidance_scale": { + "name": "escala_de_guía", + "tooltip": "Un valor más alto hace que la imagen siga la instrucción más de cerca." + }, + "image": { + "name": "imagen" + }, + "mask": { + "name": "máscara", + "tooltip": "Si se omite, la edición se aplica a toda la imagen." + }, + "model": { + "name": "modelo" + }, + "moderation": { + "name": "moderación", + "tooltip": "Configuración de moderación" + }, + "moderation_prompt_content_moderation": { + "name": "moderación_de_contenido_de_instrucción" + }, + "moderation_visual_input_moderation": { + "name": "moderación_visual_de_entrada" + }, + "moderation_visual_output_moderation": { + "name": "moderación_visual_de_salida" + }, + "negative_prompt": { + "name": "instrucción_negativa" + }, + "prompt": { + "name": "instrucción", + "tooltip": "Instrucción para editar la imagen" + }, + "seed": { + "name": "semilla" + }, + "steps": { + "name": "pasos" + }, + "structured_prompt": { + "name": "instrucción_estructurada", + "tooltip": "Una cadena que contiene la instrucción de edición estructurada en formato JSON. Usa esto en lugar de la instrucción habitual para un control preciso y programático." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "instrucción_estructurada", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Generar video usando prompt y primer y último fotograma.", "display_name": "ByteDance Primer-Último-Fotograma a Video", @@ -351,6 +413,10 @@ "name": "primer_fotograma", "tooltip": "Primer fotograma que se utilizará para el video." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "last_frame": { "name": "último_fotograma", "tooltip": "Último fotograma que se utilizará para el video." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "Editar imágenes usando modelos ByteDance a través de API basado en prompt", - "display_name": "Edición de Imágenes ByteDance", - "inputs": { - "control_after_generate": { - "name": "controlar después de generar" - }, - "guidance_scale": { - "name": "escala_de_guía", - "tooltip": "Un valor más alto hace que la imagen siga más de cerca el prompt" - }, - "image": { - "name": "imagen", - "tooltip": "La imagen base para editar" - }, - "model": { - "name": "modelo" - }, - "prompt": { - "name": "prompt", - "tooltip": "Instrucción para editar la imagen" - }, - "seed": { - "name": "semilla", - "tooltip": "Semilla a utilizar para la generación" - }, - "watermark": { - "name": "marca_de_agua", - "tooltip": "Si se debe añadir una marca de agua \"Generado por IA\" a la imagen" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "Generar imágenes usando modelos ByteDance a través de API basado en prompt", "display_name": "Imagen ByteDance", @@ -527,6 +556,10 @@ "name": "duración", "tooltip": "La duración del video de salida en segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "image": { "name": "imagen", "tooltip": "Primer fotograma a usar para el video." @@ -634,6 +667,10 @@ "name": "duración", "tooltip": "La duración del video de salida en segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parámetro se ignora para cualquier modelo excepto seedance-1-5-pro." + }, "model": { "name": "modelo" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "elección" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "ÍNDICE", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "Cargar Modelo ControlNet (diff)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "Cargar 3D", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "alto" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "archivo_modelo" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "ancho" } @@ -6270,13 +6306,11 @@ "description": "Carga una imagen desde la carpeta de salida. Cuando se hace clic en el botón de actualizar, el nodo actualizará la lista de imágenes y seleccionará automáticamente la primera imagen, permitiendo una fácil iteración.", "display_name": "Cargar Imagen (desde Salidas)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "imagen" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "elige archivo para subir" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "Aplica LoRA en modo bypass. A diferencia de LoRA regular, esto no modifica los pesos del modelo, sino que inyecta el cálculo de LoRA durante la pasada hacia adelante. Útil para escenarios de entrenamiento.", + "display_name": "Cargar LoRA (Bypass) (Para depuración)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "El modelo CLIP al que se aplicará LoRA." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "El nombre de la LoRA." + }, + "model": { + "name": "model", + "tooltip": "El modelo de difusión al que se aplicará LoRA." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "Qué tanto modificar el modelo CLIP. Este valor puede ser negativo." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "Qué tanto modificar el modelo de difusión. Este valor puede ser negativo." + } + }, + "outputs": { + "0": { + "tooltip": "El modelo de difusión modificado." + }, + "1": { + "tooltip": "El modelo CLIP modificado." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "Aplica LoRA en modo bypass. A diferencia de LoRA regular, esto no modifica los pesos del modelo, sino que inyecta el cálculo de LoRA durante la pasada hacia adelante. Útil para escenarios de entrenamiento.", + "display_name": "Cargar LoRA (Bypass, Solo Modelo) (para depuración)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "El modelo de difusión modificado." + } + } + }, "LoraLoaderModelOnly": { "description": "Las LoRAs se utilizan para modificar los modelos de difusión y CLIP, alterando la forma en que se desruidan los latentes, como la aplicación de estilos. Se pueden vincular varios nodos de LoRA.", "display_name": "CargadorLoRAModeloSolo", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "Ilumina una imagen con ajustes de luz y transferencia de luz basada en referencia opcional.", + "display_name": "Magnific Relight de Imagen", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Opciones de ajuste fino para control avanzado de la iluminación." + }, + "change_background": { + "name": "change_background", + "tooltip": "Modifica el fondo según el prompt o la referencia." + }, + "image": { + "name": "image", + "tooltip": "La imagen a iluminar." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Restringe la libertad de generación para que coincida más con el original." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Intensidad de la aplicación de transferencia de luz." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Mantiene la textura y los detalles finos del original." + }, + "prompt": { + "name": "prompt", + "tooltip": "Guía descriptiva para la iluminación. Soporta notación de énfasis (1-1.4)." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Imagen de referencia opcional de la cual transferir la iluminación." + }, + "style": { + "name": "style", + "tooltip": "Preferencia de estilo de salida." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "Mejora de piel para retratos con múltiples modos de procesamiento.", + "display_name": "Magnific Mejorador de Piel en Imagen", + "inputs": { + "image": { + "name": "image", + "tooltip": "La imagen de retrato a mejorar." + }, + "mode": { + "name": "mode", + "tooltip": "Modo de procesamiento: creativo para mejora artística, fiel para preservar la apariencia original, flexible para optimización dirigida." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Nivel de intensidad de enfoque." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Nivel de intensidad de grano inteligente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "Transfiere el estilo de una imagen de referencia a tu imagen de entrada.", + "display_name": "Transferencia de Estilo de Imagen Magnific", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "Selección del motor de procesamiento." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "Si está desactivado, cada generación introducirá cierto grado de aleatoriedad, lo que dará lugar a resultados más diversos." + }, + "flavor": { + "name": "flavor", + "tooltip": "Tipo de transferencia de estilo." + }, + "image": { + "name": "image", + "tooltip": "La imagen a la que se aplicará la transferencia de estilo." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Activa el modo retrato para mejoras faciales." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "La imagen de referencia de la que se extraerá el estilo." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Mantiene la estructura de la imagen original." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Porcentaje de intensidad del estilo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "Modifica la guía para escalar más en la 'dirección' del indicador positivo en lugar de la diferencia entre el indicador negativo.", "display_name": "¡Mahiro es tan linda que merece una mejor función de guía!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "Vista previa de cualquier", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "fuente" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "Redimensiona una imagen o máscara utilizando varios métodos de escalado.", "display_name": "Redimensionar Imagen/Máscara", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "Selecciona cómo redimensionar: por dimensiones exactas, factor de escala, igualando otra imagen, etc." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "recortar" + }, + "resize_type_height": { + "name": "altura" + }, + "resize_type_width": { + "name": "anchura" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "Algoritmo de interpolación. 'area' es mejor para reducir tamaño, 'lanczos' para aumentar tamaño, 'nearest-exact' para pixel art." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: Imagen(es) a Modelo (Pro)", + "inputs": { + "control_after_generate": { + "name": "controlar_tras_generar" + }, + "face_count": { + "name": "número_de_caras" + }, + "generate_type": { + "name": "tipo_de_generación" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "imagen" + }, + "image_back": { + "name": "imagen_trasera" + }, + "image_left": { + "name": "imagen_izquierda" + }, + "image_right": { + "name": "imagen_derecha" + }, + "model": { + "name": "modelo", + "tooltip": "La opción LowPoly no está disponible para el modelo `3.1`." + }, + "seed": { + "name": "semilla", + "tooltip": "La semilla controla si el nodo debe volver a ejecutarse; los resultados son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "name": "archivo_modelo", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: Texto a Modelo (Pro)", + "inputs": { + "control_after_generate": { + "name": "controlar_tras_generar" + }, + "face_count": { + "name": "número_de_caras" + }, + "generate_type": { + "name": "tipo_de_generación" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "modelo", + "tooltip": "La opción LowPoly no está disponible para el modelo `3.1`." + }, + "prompt": { + "name": "prompt", + "tooltip": "Admite hasta 1024 caracteres." + }, + "seed": { + "name": "semilla", + "tooltip": "La semilla controla si el nodo debe volver a ejecutarse; los resultados son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "name": "archivo_modelo", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "auto_redimensionar_imágenes" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "imagen1" + }, + "image2": { + "name": "imagen2" + }, + "image3": { + "name": "imagen3" + }, + "image_encoder": { + "name": "codificador_de_imagen" + }, + "prompt": { + "name": "instrucción" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Convertir texto a minúsculas", "inputs": { @@ -13643,6 +13970,10 @@ "name": "bucket_mode", "tooltip": "Habilita el modo de resolución por grupos. Cuando está habilitado, espera latentes preagrupados del nodo ResolutionBucket." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Activa el modo bypass para el entrenamiento. Cuando está activado, los adaptadores se aplican mediante hooks directos en vez de modificar los pesos. Útil para modelos cuantizados donde los pesos no pueden modificarse directamente." + }, "control_after_generate": { "name": "controlar después de generar" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "salida codificador de audio 1" + }, + "audio_scale": { + "name": "escala de audio" + }, + "clip_vision_output": { + "name": "salida de clip visión" + }, + "height": { + "name": "alto" + }, + "length": { + "name": "longitud" + }, + "mode": { + "name": "modo" + }, + "model": { + "name": "modelo" + }, + "model_patch": { + "name": "parche de modelo" + }, + "motion_frame_count": { + "name": "número de fotogramas de movimiento", + "tooltip": "Número de fotogramas anteriores a usar como contexto de movimiento." + }, + "negative": { + "name": "negativo" + }, + "positive": { + "name": "positivo" + }, + "previous_frames": { + "name": "fotogramas anteriores" + }, + "start_image": { + "name": "imagen inicial" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "ancho" + } + }, + "outputs": { + "0": { + "name": "modelo", + "tooltip": null + }, + "1": { + "name": "positivo", + "tooltip": null + }, + "2": { + "name": "negativo", + "tooltip": null + }, + "3": { + "name": "latente", + "tooltip": null + }, + "4": { + "name": "imagen recortada", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "Escalador de video rápido y de alta calidad que aumenta la resolución y restaura la claridad de videos de baja resolución o borrosos.", + "display_name": "FlashVSR Escalado de Video", + "inputs": { + "target_resolution": { + "name": "resolución_objetivo" + }, + "video": { + "name": "video" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "Aumenta la resolución y calidad de la imagen, escalando fotos a 4K u 8K para obtener resultados nítidos y detallados.", + "display_name": "WaveSpeed Escalado de Imagen", + "inputs": { + "image": { + "name": "imagen" + }, + "model": { + "name": "modelo" + }, + "target_resolution": { + "name": "resolución_objetivo" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Captura de Webcam", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "imagen" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "ancho" } diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 1d02ade05..68a482f7b 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Mostrar caja de herramientas de selección" + "name": "Mostrar caja de herramientas de selección", + "tooltip": "Muestra una barra de herramientas flotante cuando se seleccionan nodos, proporcionando acceso rápido a acciones comunes." }, "Comfy_ConfirmClear": { "name": "Requerir confirmación al borrar el flujo de trabajo" @@ -147,7 +148,8 @@ "Linear": "Lineal", "Spline": "Spline", "Straight": "Recto" - } + }, + "tooltip": "Controla la apariencia y visibilidad de los enlaces de conexión entre nodos en el lienzo." }, "Comfy_Load3D_3DViewerEnable": { "name": "Habilitar visor 3D (Beta)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Mostrar ancho × altura debajo de la vista previa de la imagen" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Mostrar siempre los widgets avanzados en todos los nodos", + "tooltip": "Cuando está activado, los widgets avanzados siempre son visibles en todos los nodos sin necesidad de expandirlos individualmente." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Enlace de ajuste automático a la ranura del nodo", "tooltip": "Al arrastrar un enlace sobre un nodo, el enlace se ajustará automáticamente a una ranura de entrada viable en el nodo" @@ -332,6 +338,10 @@ "name": "Tamaño del historial de la cola", "tooltip": "El número máximo de tareas que se muestran en el historial de la cola." }, + "Comfy_Queue_QPOV2": { + "name": "Usar la cola de trabajos unificada en el panel lateral de Activos", + "tooltip": "Reemplaza el panel flotante de la cola de trabajos por una cola de trabajos equivalente integrada en el panel lateral de Activos. Puedes desactivar esto para volver al diseño del panel flotante." + }, "Comfy_Sidebar_Location": { "name": "Ubicación de la barra lateral", "options": { @@ -466,6 +476,7 @@ "tooltip": "El punto de control bezier desplazado desde el punto central de reenrutamiento" }, "pysssss_SnapToGrid": { - "name": "Siempre ajustar a la cuadrícula" + "name": "Siempre ajustar a la cuadrícula", + "tooltip": "Cuando está activado, los nodos se alinearán automáticamente a la cuadrícula al moverlos o cambiar su tamaño." } } diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index d4c673259..ffa846a76 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -24,6 +24,7 @@ "assets": "دارایی‌ها", "baseModels": "مدل‌های پایه", "browseAssets": "مرور دارایی‌ها", + "byType": "بر اساس نوع", "checkpoints": "چک‌پوینت‌ها", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "مثال:", @@ -45,6 +46,10 @@ "failed": "دانلود ناموفق بود", "inProgress": "در حال دانلود {assetName}..." }, + "emptyImported": { + "canImport": "هنوز مدلی وارد نشده است. برای افزودن مدل خود، روی «وارد کردن مدل» کلیک کنید.", + "restricted": "مدل‌های شخصی فقط برای سطح Creator و بالاتر در دسترس هستند." + }, "errorFileTooLarge": "فایل از حداکثر اندازه مجاز بزرگ‌تر است", "errorFormatNotAllowed": "فقط فرمت SafeTensor مجاز است", "errorModelTypeNotSupported": "این نوع مدل پشتیبانی نمی‌شود", @@ -61,6 +66,7 @@ "finish": "پایان", "genericLinkPlaceholder": "لینک را اینجا وارد کنید", "importAnother": "وارد کردن مورد دیگر", + "imported": "وارد شده", "jobId": "شناسه کار: {jobId}", "loadingModels": "در حال بارگذاری {type}...", "maxFileSize": "حداکثر اندازه فایل: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "مدل سه‌بعدی" }, "modelAssociatedWithLink": "مدل مرتبط با لینکی که وارد کردید:", + "modelInfo": { + "addBaseModel": "افزودن مدل پایه...", + "addTag": "افزودن برچسب...", + "additionalTags": "برچسب‌های اضافی", + "baseModelUnknown": "مدل پایه نامشخص", + "basicInfo": "اطلاعات پایه", + "compatibleBaseModels": "مدل‌های پایه سازگار", + "description": "توضیحات", + "descriptionNotSet": "توضیحی تنظیم نشده است", + "descriptionPlaceholder": "یک توضیح برای این مدل اضافه کنید...", + "displayName": "نام نمایشی", + "editDisplayName": "ویرایش نام نمایشی", + "fileName": "نام فایل", + "modelDescription": "توضیحات مدل", + "modelTagging": "برچسب‌گذاری مدل", + "modelType": "نوع مدل", + "noAdditionalTags": "برچسب اضافی وجود ندارد", + "selectModelPrompt": "یک مدل را برای مشاهده اطلاعات آن انتخاب کنید", + "selectModelType": "انتخاب نوع مدل...", + "source": "منبع", + "title": "اطلاعات مدل", + "triggerPhrases": "عبارات فعال‌ساز", + "viewOnSource": "مشاهده در {source}" + }, "modelName": "نام مدل", "modelNamePlaceholder": "یک نام برای این مدل وارد کنید", "modelTypeSelectorLabel": "نوع مدل چیست؟", @@ -238,6 +268,12 @@ "title": "ایجاد حساب کاربری" } }, + "boundingBox": { + "height": "ارتفاع", + "width": "عرض", + "x": "ایکس", + "y": "وای" + }, "breadcrumbsMenu": { "clearWorkflow": "پاک‌سازی workflow", "deleteBlueprint": "حذف blueprint", @@ -678,6 +714,7 @@ "clearAll": "پاک‌سازی همه", "clearFilters": "پاک‌سازی فیلترها", "close": "بستن", + "closeDialog": "بستن پنجره", "color": "رنگ", "comfy": "Comfy", "comfyOrgLogoAlt": "لوگوی ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "کنترل پیش از تولید", "copied": "کپی شد", "copy": "کپی", + "copyAll": "کپی همه", "copyJobId": "کپی شناسه وظیفه", "copyToClipboard": "کپی در کلیپ‌بورد", "copyURL": "کپی آدرس", @@ -756,6 +794,8 @@ "goToNode": "رفتن به node", "graphNavigation": "ناوبری گراف", "halfSpeed": "۰.۵x", + "hideLeftPanel": "پنهان کردن پنل چپ", + "hideRightPanel": "پنهان کردن پنل راست", "icon": "آیکون", "imageFailedToLoad": "بارگذاری تصویر ناموفق بود", "imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید", @@ -797,6 +837,7 @@ "name": "نام", "newFolder": "پوشه جدید", "next": "بعدی", + "nightly": "نسخه شبانه", "no": "خیر", "noAudioRecorded": "هیچ صدایی ضبط نشد", "noItems": "هیچ موردی وجود ندارد", @@ -811,6 +852,7 @@ "nodeSlotsError": "خطا در slotهای node", "nodeWidgetsError": "خطا در ابزارک‌های node", "nodes": "nodeها", + "nodesCount": "{count} نود | {count} نود | {count} نود", "nodesRunning": "nodeها در حال اجرا هستند", "none": "هیچ‌کدام", "nothingToCopy": "موردی برای کپی وجود ندارد", @@ -885,7 +927,9 @@ "selectedFile": "فایل انتخاب‌شده", "setAsBackground": "تنظیم به عنوان پس‌زمینه", "settings": "تنظیمات", + "showLeftPanel": "نمایش پنل چپ", "showReport": "نمایش گزارش", + "showRightPanel": "نمایش پنل راست", "singleSelectDropdown": "لیست کشویی تک‌انتخابی", "sort": "مرتب‌سازی", "source": "منبع", @@ -908,6 +952,7 @@ "updating": "در حال به‌روزرسانی {id}", "upload": "بارگذاری", "usageHint": "راهنمای استفاده", + "use": "استفاده", "user": "کاربر", "versionMismatchWarning": "هشدار ناسازگاری نسخه", "versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.", @@ -915,11 +960,10 @@ "videoPreview": "پیش‌نمایش ویدیو - برای جابجایی بین ویدیوها از کلیدهای جهت‌دار استفاده کنید", "viewImageOfTotal": "مشاهده تصویر {index} از {total}", "viewVideoOfTotal": "مشاهده ویدیو {index} از {total}", - "vitePreloadErrorMessage": "نسخه جدیدی از برنامه منتشر شده است. آیا مایل به بارگذاری مجدد هستید؟\nدر غیر این صورت، برخی بخش‌های برنامه ممکن است به درستی کار نکنند.\nمی‌توانید رد کنید و قبل از بارگذاری مجدد، پیشرفت خود را ذخیره کنید.", - "vitePreloadErrorTitle": "نسخه جدید موجود است", "volume": "حجم صدا", "warning": "هشدار", - "workflow": "workflow" + "workflow": "workflow", + "you": "شما" }, "graphCanvasMenu": { "fitView": "تطبیق با نما", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "تصویری برای مقایسه وجود ندارد" }, + "imageCrop": { + "cropPreviewAlt": "پیش‌نمایش برش", + "loading": "در حال بارگذاری...", + "noInputImage": "هیچ تصویر ورودی متصل نیست" + }, "importFailed": { "copyError": "خطا در کپی", "title": "وارد کردن ناموفق بود" @@ -1606,17 +1655,25 @@ "title": "این workflow دارای nodeهای مفقود است" } }, + "nightly": { + "badge": { + "label": "نسخه پیش‌نمایش", + "tooltip": "شما در حال استفاده از نسخه شبانه ComfyUI هستید. لطفاً با استفاده از دکمه بازخورد، نظرات خود را درباره این قابلیت‌ها به اشتراک بگذارید." + } + }, "nodeCategories": { "": "", "3d": "سه‌بعدی", "3d_models": "مدل‌های سه‌بعدی", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_for_testing", "advanced": "پیشرفته", "animation": "انیمیشن", @@ -1841,6 +1900,7 @@ }, "groupSettings": "تنظیمات گروه", "groups": "گروه‌ها", + "hideAdvancedInputsButton": "مخفی کردن ورودی‌های پیشرفته", "hideInput": "مخفی کردن ورودی", "info": "اطلاعات", "inputs": "ورودی‌ها", @@ -2073,6 +2133,7 @@ "NodeLibrary": "کتابخانه Node", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "تنظیمات اعلان", + "Other": "سایر", "PLY": "PLY", "PlanCredits": "طرح و اعتبارات", "Pointer": "اشاره‌گر", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Nodes 2.0", "VueNodes": "Nodes 2.0", "Window": "پنجره", - "Workflow": "Workflow" + "Workflow": "Workflow", + "Workspace": "محیط کاری" }, "shape": { "CARD": "کارت", @@ -2118,12 +2180,14 @@ "viewControls": "کنترل‌های نمایش" }, "sideToolbar": { + "activeJobStatus": "وضعیت کار فعال: {status}", "assets": "دارایی‌ها", "backToAssets": "بازگشت به همه دارایی‌ها", "browseTemplates": "مرور قالب‌های نمونه", "downloads": "دانلودها", "generatedAssetsHeader": "دارایی‌های تولیدشده", "helpCenter": "مرکز راهنما", + "importedAssetsHeader": "دارایی‌های واردشده", "labels": { "assets": "دارایی‌ها", "console": "کنسول", @@ -2179,6 +2243,7 @@ "queue": "صف", "queueProgressOverlay": { "activeJobs": "{count} کار فعال", + "activeJobsShort": "{count} فعال | {count} فعال", "activeJobsSuffix": "کار فعال", "cancelJobTooltip": "لغو کار", "clearHistory": "پاک‌سازی تاریخچه صف کار", @@ -2267,9 +2332,15 @@ "beta": "بتا", "billedMonthly": "صورتحساب ماهانه", "billedYearly": "{total} صورتحساب سالانه", + "billingComingSoon": { + "message": "صورت‌حساب تیمی به‌زودی ارائه می‌شود. به‌زودی می‌توانید برای فضای کاری خود با قیمت هر نفر اشتراک تهیه کنید. برای به‌روزرسانی‌ها همراه ما باشید.", + "title": "به‌زودی" + }, + "cancelSubscription": "لغو اشتراک", "changeTo": "تغییر به {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "لوگوی Comfy Cloud", + "contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید", "contactUs": "تماس با ما", "creditsRemainingThisMonth": "شامل شده (شارژ مجدد {date})", "creditsRemainingThisYear": "شامل شده (شارژ مجدد {date})", @@ -2282,6 +2353,7 @@ "haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟", "invoiceHistory": "تاریخچه فاکتورها", "learnMore": "اطلاعات بیشتر", + "managePayment": "مدیریت پرداخت", "managePlan": "مدیریت طرح", "manageSubscription": "مدیریت اشتراک", "maxDuration": { @@ -2317,6 +2389,7 @@ "subscribeToComfyCloud": "اشتراک در Comfy Cloud", "subscribeToRun": "اشتراک", "subscribeToRunFull": "اشتراک برای اجرا", + "subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.", "tierNameYearly": "{name} سالانه", "tiers": { "creator": { @@ -2348,6 +2421,7 @@ "viewMoreDetails": "مشاهده جزئیات بیشتر", "viewMoreDetailsPlans": "مشاهده جزئیات بیشتر درباره طرح‌ها و قیمت‌ها", "viewUsageHistory": "مشاهده تاریخچه استفاده", + "workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد", "yearly": "سالانه", "yearlyCreditsLabel": "کل اعتبار سالانه", "yearlyDiscount": "٪۲۰ تخفیف", @@ -2449,6 +2523,7 @@ "failedToLoadModel": "بارگذاری مدل سه‌بعدی انجام نشد", "failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}", "failedToQueue": "صف‌بندی انجام نشد", + "failedToSaveDraft": "ذخیره پیش‌نویس workflow ناموفق بود", "failedToToggleCamera": "تغییر وضعیت دوربین انجام نشد", "failedToToggleGrid": "تغییر وضعیت شبکه انجام نشد", "failedToUpdateBackgroundColor": "به‌روزرسانی رنگ پس‌زمینه انجام نشد", @@ -2497,7 +2572,8 @@ "notSet": "تنظیم نشده", "provider": "ارائه‌دهنده ورود", "title": "تنظیمات حساب کاربری من", - "updatePassword": "به‌روزرسانی گذرواژه" + "updatePassword": "به‌روزرسانی گذرواژه", + "workspaceSettings": "تنظیمات محیط کاری" }, "validation": { "descriptionRequired": "توضیحات الزامی است", @@ -2588,6 +2664,9 @@ "saveWorkflow": "ذخیره workflow" }, "workspace": { + "addedToWorkspace": "شما به {workspaceName} اضافه شدید", + "inviteAccepted": "دعوت پذیرفته شد", + "inviteFailed": "پذیرش دعوت ناموفق بود", "unsavedChanges": { "message": "شما تغییرات ذخیره‌نشده دارید. آیا می‌خواهید آن‌ها را رها کرده و فضای کاری را تغییر دهید؟", "title": "تغییرات ذخیره‌نشده" @@ -2602,6 +2681,128 @@ "workspaceNotFound": "فضای کاری پیدا نشد." } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "ایجاد", + "message": "محیط‌های کاری به اعضا اجازه می‌دهند از یک اعتبار مشترک استفاده کنند. پس از ایجاد، شما مالک خواهید بود.", + "nameLabel": "نام محیط کاری*", + "namePlaceholder": "نام محیط کاری را وارد کنید", + "title": "ایجاد محیط کاری جدید" + }, + "dashboard": { + "placeholder": "تنظیمات داشبورد فضای کاری" + }, + "deleteDialog": { + "message": "هرگونه اعتبار یا دارایی ذخیره‌نشده از بین خواهد رفت. این عملیات قابل بازگشت نیست.", + "messageWithName": "حذف «{name}»؟ هرگونه اعتبار یا دارایی ذخیره‌نشده از بین خواهد رفت. این عملیات قابل بازگشت نیست.", + "title": "حذف این محیط کاری؟" + }, + "editWorkspaceDialog": { + "nameLabel": "نام محیط کاری", + "save": "ذخیره", + "title": "ویرایش جزئیات محیط کاری" + }, + "invite": "دعوت", + "inviteLimitReached": "شما به حداکثر تعداد ۵۰ عضو رسیده‌اید", + "inviteMember": "دعوت عضو", + "inviteMemberDialog": { + "createLink": "ایجاد لینک", + "linkCopied": "کپی شد", + "linkCopyFailed": "کپی لینک ناموفق بود", + "linkStep": { + "copyLink": "کپی لینک", + "done": "انجام شد", + "message": "اطمینان حاصل کنید که حساب کاربری او از این ایمیل استفاده می‌کند.", + "title": "این لینک را برای شخص ارسال کنید" + }, + "message": "یک لینک دعوت قابل اشتراک‌گذاری برای ارسال به شخص ایجاد کنید", + "placeholder": "ایمیل شخص را وارد کنید", + "title": "دعوت یک نفر به این فضای کاری" + }, + "leaveDialog": { + "leave": "خروج", + "message": "تا زمانی که با مالک محیط کاری تماس نگیرید، امکان پیوستن مجدد نخواهید داشت.", + "title": "خروج از این محیط کاری؟" + }, + "members": { + "actions": { + "copyLink": "کپی لینک دعوت", + "removeMember": "حذف عضو", + "revokeInvite": "لغو دعوت" + }, + "columns": { + "expiryDate": "تاریخ انقضا", + "inviteDate": "تاریخ دعوت", + "joinDate": "تاریخ پیوستن" + }, + "createNewWorkspace": "یک فضای کاری جدید ایجاد کنید.", + "membersCount": "{count}/۵۰ عضو", + "noInvites": "هیچ دعوت‌نامه‌ای در انتظار نیست", + "noMembers": "هیچ عضوی وجود ندارد", + "pendingInvitesCount": "{count} دعوت‌نامه در انتظار | {count} دعوت‌نامه در انتظار", + "personalWorkspaceMessage": "در حال حاضر نمی‌توانید اعضای دیگری به فضای کاری شخصی خود دعوت کنید. برای افزودن اعضا به یک فضای کاری،", + "tabs": { + "active": "فعال", + "pendingCount": "در انتظار ({count})" + } + }, + "menu": { + "deleteWorkspace": "حذف محیط کاری", + "deleteWorkspaceDisabledTooltip": "ابتدا اشتراک فعال محیط کاری خود را لغو کنید", + "editWorkspace": "ویرایش جزئیات محیط کاری", + "leaveWorkspace": "خروج از محیط کاری" + }, + "removeMemberDialog": { + "error": "حذف عضو ناموفق بود", + "message": "این عضو از فضای کاری شما حذف خواهد شد. اعتباراتی که استفاده کرده‌اند بازگردانده نخواهد شد.", + "remove": "حذف عضو", + "success": "عضو حذف شد", + "title": "این عضو حذف شود؟" + }, + "revokeInviteDialog": { + "message": "این عضو دیگر نمی‌تواند به فضای کاری شما بپیوندد. لینک دعوت او نامعتبر خواهد شد.", + "revoke": "لغو دعوت", + "title": "دعوت این شخص لغو شود؟" + }, + "tabs": { + "dashboard": "داشبورد", + "membersCount": "اعضا ({count})", + "planCredits": "پلن و اعتبارها" + }, + "toast": { + "failedToCreateWorkspace": "ایجاد محیط کاری ناموفق بود", + "failedToDeleteWorkspace": "حذف محیط کاری ناموفق بود", + "failedToFetchWorkspaces": "بارگذاری فضاهای کاری ناموفق بود", + "failedToLeaveWorkspace": "خروج از محیط کاری ناموفق بود", + "failedToUpdateWorkspace": "به‌روزرسانی محیط کاری ناموفق بود", + "workspaceCreated": { + "message": "برای یک طرح اشتراک ثبت‌نام کنید، هم‌تیمی‌ها را دعوت کنید و همکاری را آغاز نمایید.", + "subscribe": "اشتراک", + "title": "فضای کاری ایجاد شد" + }, + "workspaceDeleted": { + "message": "فضای کاری به طور دائمی حذف شد.", + "title": "فضای کاری حذف شد" + }, + "workspaceLeft": { + "message": "شما فضای کاری را ترک کردید.", + "title": "ترک فضای کاری" + }, + "workspaceUpdated": { + "message": "جزئیات محیط کاری ذخیره شد.", + "title": "محیط کاری به‌روزرسانی شد" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "ایجاد محیط کاری جدید", + "maxWorkspacesReached": "شما فقط می‌توانید مالک ۱۰ محیط کاری باشید. برای ایجاد محیط کاری جدید، یکی را حذف کنید.", + "personal": "شخصی", + "roleMember": "عضو", + "roleOwner": "مالک", + "subscribe": "اشتراک", + "switchWorkspace": "تغییر محیط کاری" + }, "zoomControls": { "hideMinimap": "مخفی‌سازی نقشه کوچک", "label": "کنترل‌های بزرگ‌نمایی", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index f9859059c..c31ad2571 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "ویرایش تصاویر با استفاده از جدیدترین مدل Bria", + "display_name": "ویرایش تصویر Bria", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "guidance_scale": { + "name": "مقیاس راهنما", + "tooltip": "مقدار بالاتر باعث می‌شود تصویر بیشتر از پرامپت پیروی کند." + }, + "image": { + "name": "تصویر" + }, + "mask": { + "name": "ماسک", + "tooltip": "در صورت عدم انتخاب، ویرایش بر کل تصویر اعمال می‌شود." + }, + "model": { + "name": "مدل" + }, + "moderation": { + "name": "تنظیمات نظارت", + "tooltip": "تنظیمات نظارت" + }, + "moderation_prompt_content_moderation": { + "name": "نظارت بر محتوای پرامپت" + }, + "moderation_visual_input_moderation": { + "name": "نظارت بر ورودی تصویری" + }, + "moderation_visual_output_moderation": { + "name": "نظارت بر خروجی تصویری" + }, + "negative_prompt": { + "name": "پرامپت منفی" + }, + "prompt": { + "name": "پرامپت", + "tooltip": "دستورالعمل برای ویرایش تصویر" + }, + "seed": { + "name": "بذر" + }, + "steps": { + "name": "گام‌ها" + }, + "structured_prompt": { + "name": "پرامپت ساختاریافته", + "tooltip": "یک رشته شامل پرامپت ویرایش ساختاریافته در قالب JSON. برای کنترل دقیق و برنامه‌نویسی شده، به جای پرامپت معمولی از این گزینه استفاده کنید." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "پرامپت ساختاریافته", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.", "display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance", @@ -351,6 +413,10 @@ "name": "اولین فریم", "tooltip": "اولین فریم مورد استفاده برای ویدیو." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "last_frame": { "name": "آخرین فریم", "tooltip": "آخرین فریم مورد استفاده برای ویدیو." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "ویرایش تصاویر با استفاده از مدل‌های ByteDance از طریق API بر اساس پرامپت", - "display_name": "ویرایش تصویر ByteDance", - "inputs": { - "control_after_generate": { - "name": "کنترل پس از تولید" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "مقدار بالاتر باعث می‌شود تصویر بیشتر از پرامپت پیروی کند" - }, - "image": { - "name": "تصویر", - "tooltip": "تصویر پایه برای ویرایش" - }, - "model": { - "name": "مدل" - }, - "prompt": { - "name": "پرامپت", - "tooltip": "دستورالعمل ویرایش تصویر" - }, - "seed": { - "name": "seed", - "tooltip": "seed مورد استفاده برای تولید" - }, - "watermark": { - "name": "واترمارک", - "tooltip": "آیا واترمارک «تولید شده توسط هوش مصنوعی» به تصویر اضافه شود یا خیر" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "تولید تصویر با استفاده از مدل‌های ByteDance از طریق API بر اساس پرامپت", "display_name": "تصویر ByteDance", @@ -527,6 +556,10 @@ "name": "مدت زمان", "tooltip": "مدت زمان ویدیوی خروجی بر حسب ثانیه." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "image": { "name": "تصویر", "tooltip": "اولین فریم مورد استفاده برای ویدیو." @@ -634,6 +667,10 @@ "name": "مدت زمان", "tooltip": "مدت زمان ویدیوی خروجی بر حسب ثانیه." }, + "generate_audio": { + "name": "تولید صدا", + "tooltip": "این پارامتر فقط برای مدل seedance-1-5-pro معتبر است و برای سایر مدل‌ها نادیده گرفته می‌شود." + }, "model": { "name": "مدل" }, @@ -2021,14 +2058,16 @@ "choice": { "name": "انتخاب" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "INDEX", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "بارگذاری مدل ControlNet (diff)", @@ -6176,8 +6215,7 @@ "Load3D": { "display_name": "بارگذاری ۳بعدی و انیمیشن", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "ارتفاع" }, @@ -6187,10 +6225,8 @@ "model_file": { "name": "فایل مدل" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "عرض" } @@ -6285,13 +6321,11 @@ "description": "بارگذاری یک تصویر از پوشه خروجی. با کلیک روی دکمه تازه‌سازی، فهرست تصاویر به‌روزرسانی شده و اولین تصویر به طور خودکار انتخاب می‌شود تا تکرار آسان‌تر شود.", "display_name": "بارگذاری تصویر (از خروجی‌ها)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "تصویر" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "انتخاب فایل برای بارگذاری" } @@ -6393,6 +6427,60 @@ } } }, + "LoraLoaderBypass": { + "description": "اعمال LoRA در حالت bypass. برخلاف LoRA معمولی، این روش وزن‌های مدل را تغییر نمی‌دهد بلکه محاسبات LoRA را در طول عبور رو به جلو تزریق می‌کند. مناسب برای سناریوهای آموزش.", + "display_name": "بارگذاری LoRA (حالت Bypass) (برای اشکال‌زدایی)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "مدل CLIP که LoRA بر روی آن اعمال می‌شود." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "نام LoRA." + }, + "model": { + "name": "مدل", + "tooltip": "مدل diffusion که LoRA بر روی آن اعمال می‌شود." + }, + "strength_clip": { + "name": "قدرت_clip", + "tooltip": "میزان شدت تغییر مدل CLIP. این مقدار می‌تواند منفی باشد." + }, + "strength_model": { + "name": "قدرت_مدل", + "tooltip": "میزان شدت تغییر مدل diffusion. این مقدار می‌تواند منفی باشد." + } + }, + "outputs": { + "0": { + "tooltip": "مدل diffusion تغییر یافته." + }, + "1": { + "tooltip": "مدل CLIP تغییر یافته." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "اعمال LoRA در حالت bypass. برخلاف LoRA معمولی، این روش وزن‌های مدل را تغییر نمی‌دهد بلکه محاسبات LoRA را در طول عبور رو به جلو تزریق می‌کند. مناسب برای سناریوهای آموزش.", + "display_name": "بارگذاری LoRA (حالت Bypass، فقط مدل) (برای اشکال‌زدایی)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "مدل" + }, + "strength_model": { + "name": "قدرت_مدل" + } + }, + "outputs": { + "0": { + "tooltip": "مدل diffusion تغییر یافته." + } + } + }, "LoraLoaderModelOnly": { "description": "LoRAها برای تغییر مدل‌های diffusion و CLIP استفاده می‌شوند و نحوه حذف نویز از latents را تغییر می‌دهند، مانند اعمال سبک‌ها. چندین node LoRA می‌توانند به هم متصل شوند.", "display_name": "LoraLoaderModelOnly", @@ -6761,6 +6849,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "بازنورپردازی تصویر با تنظیمات نور و انتقال نور مبتنی بر مرجع (اختیاری).", + "display_name": "بازنورپردازی تصویر Magnific", + "inputs": { + "advanced_settings": { + "name": "تنظیمات_پیشرفته", + "tooltip": "گزینه‌های تنظیم دقیق برای کنترل پیشرفته نورپردازی." + }, + "change_background": { + "name": "تغییر_پس‌زمینه", + "tooltip": "پس‌زمینه را بر اساس پرامپت/مرجع تغییر می‌دهد." + }, + "image": { + "name": "تصویر", + "tooltip": "تصویری که قرار است بازنورپردازی شود." + }, + "interpolate_from_original": { + "name": "درون‌یابی_از_مبدأ", + "tooltip": "آزادی تولید را محدود می‌کند تا با تصویر اصلی بیشتر مطابقت داشته باشد." + }, + "light_transfer_strength": { + "name": "شدت_انتقال_نور", + "tooltip": "شدت اعمال انتقال نور." + }, + "preserve_details": { + "name": "حفظ_جزئیات", + "tooltip": "بافت و جزئیات ریز تصویر اصلی را حفظ می‌کند." + }, + "prompt": { + "name": "پرامپت", + "tooltip": "راهنمای توصیفی برای نورپردازی. از نشانه‌گذاری تأکید (۱-۱.۴) پشتیبانی می‌کند." + }, + "reference_image": { + "name": "تصویر_مرجع", + "tooltip": "تصویر مرجع اختیاری برای انتقال نور از آن." + }, + "style": { + "name": "سبک", + "tooltip": "ترجیح خروجی از نظر سبک." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "بهبود پوست برای پرتره‌ها با حالت‌های پردازشی مختلف.", + "display_name": "بهبود پوست تصویر Magnific", + "inputs": { + "image": { + "name": "تصویر", + "tooltip": "تصویر پرتره‌ای که باید بهبود یابد." + }, + "mode": { + "name": "حالت", + "tooltip": "حالت پردازش: creative برای بهبود هنری، faithful برای حفظ ظاهر اصلی، flexible برای بهینه‌سازی هدفمند." + }, + "sharpen": { + "name": "شارپ‌سازی", + "tooltip": "سطح شدت شارپ‌سازی." + }, + "smart_grain": { + "name": "دانه‌بندی_هوشمند", + "tooltip": "سطح شدت دانه‌بندی هوشمند." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "سبک را از یک تصویر مرجع به تصویر ورودی خود منتقل کنید.", + "display_name": "انتقال سبک تصویر Magnific", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "انتخاب موتور پردازش." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "در صورت غیرفعال بودن، هر بار تولید تصویر مقداری تصادفی بودن به همراه دارد که منجر به نتایج متنوع‌تر می‌شود." + }, + "flavor": { + "name": "flavor", + "tooltip": "نوع انتقال سبک." + }, + "image": { + "name": "image", + "tooltip": "تصویری که قرار است انتقال سبک روی آن اعمال شود." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "فعال‌سازی حالت پرتره برای بهبود چهره." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "تصویر مرجعی که سبک از آن استخراج می‌شود." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "ساختار تصویر اصلی را حفظ می‌کند." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "درصد قدرت سبک." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "راهنمایی را به گونه‌ای تغییر می‌دهد که بیشتر بر «جهت» پرامپت مثبت تمرکز کند تا تفاوت بین پرامپت منفی.", "display_name": "Mahiro CFG", @@ -10377,10 +10585,8 @@ "PreviewAny": { "display_name": "پیش‌نمایش به صورت متن", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "سورس" } @@ -11213,19 +11419,28 @@ } }, "ResizeImageMaskNode": { + "description": "تغییر اندازه تصویر یا mask با استفاده از روش‌های مختلف مقیاس‌گذاری.", "display_name": "تغییر اندازه تصویر/ماسک", "inputs": { "input": { "name": "ورودی" }, "resize_type": { - "name": "نوع تغییر اندازه" + "name": "نوع تغییر اندازه", + "tooltip": "نحوه تغییر اندازه را انتخاب کنید: بر اساس ابعاد دقیق، ضریب مقیاس، تطبیق با تصویر دیگر و غیره." }, - "resize_type_multiplier": { - "name": "ضریب" + "resize_type_crop": { + "name": "برش" + }, + "resize_type_height": { + "name": "ارتفاع" + }, + "resize_type_width": { + "name": "عرض" }, "scale_method": { - "name": "روش مقیاس‌دهی" + "name": "روش مقیاس‌دهی", + "tooltip": "الگوریتم درون‌یابی. 'area' برای کاهش اندازه بهترین است، 'lanczos' برای افزایش اندازه، و 'nearest-exact' برای هنر پیکسلی مناسب است." } }, "outputs": { @@ -13370,6 +13585,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: تبدیل تصویر(ها) به مدل (پیشرفته)", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "face_count": { + "name": "تعداد وجه‌ها" + }, + "generate_type": { + "name": "نوع تولید" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "تصویر" + }, + "image_back": { + "name": "تصویر پشت" + }, + "image_left": { + "name": "تصویر چپ" + }, + "image_right": { + "name": "تصویر راست" + }, + "model": { + "name": "مدل", + "tooltip": "گزینه LowPoly برای مدل `۳.۱` در دسترس نیست." + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "name": "فایل مدل", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: تبدیل متن به مدل (پیشرفته)", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "face_count": { + "name": "تعداد وجه‌ها" + }, + "generate_type": { + "name": "نوع تولید" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "مدل", + "tooltip": "گزینه LowPoly برای مدل `۳.۱` در دسترس نیست." + }, + "prompt": { + "name": "پرامپت", + "tooltip": "حداکثر تا ۱۰۲۴ کاراکتر پشتیبانی می‌شود." + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "name": "فایل مدل", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13465,6 +13758,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "تغییر اندازه خودکار تصاویر" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "تصویر ۱" + }, + "image2": { + "name": "تصویر ۲" + }, + "image3": { + "name": "تصویر ۳" + }, + "image_encoder": { + "name": "رمزگذار تصویر" + }, + "prompt": { + "name": "پرامپت" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "تبدیل متن به حروف کوچک", "inputs": { @@ -13670,6 +13997,10 @@ "name": "حالت سطل‌بندی رزولوشن", "tooltip": "فعال‌سازی حالت سطل‌بندی رزولوشن. در صورت فعال بودن، انتظار می‌رود لاتنت‌های پیش‌سطل‌بندی شده از node ResolutionBucket دریافت شود." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "فعال‌سازی حالت bypass برای آموزش. در این حالت، آداپتورها از طریق forward hookها به جای تغییر وزن‌ها اعمال می‌شوند. این روش برای مدل‌های quantized که امکان تغییر مستقیم وزن‌ها وجود ندارد، مفید است." + }, "control_after_generate": { "name": "کنترل پس از تولید" }, @@ -13742,10 +14073,6 @@ "2": { "name": "نقشه خطا", "tooltip": "تاریخچه خطا" - }, - "3": { - "name": "مراحل", - "tooltip": "کل مراحل آموزش" } } }, @@ -15640,6 +15967,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "خروجی رمزگذار صوتی ۱" + }, + "audio_scale": { + "name": "مقیاس صوتی" + }, + "clip_vision_output": { + "name": "خروجی بینایی clip" + }, + "height": { + "name": "ارتفاع" + }, + "length": { + "name": "طول" + }, + "mode": { + "name": "حالت" + }, + "model": { + "name": "مدل" + }, + "model_patch": { + "name": "patch مدل" + }, + "motion_frame_count": { + "name": "تعداد فریم‌های حرکتی", + "tooltip": "تعداد فریم‌های قبلی که به عنوان زمینه حرکت استفاده می‌شود." + }, + "negative": { + "name": "منفی" + }, + "positive": { + "name": "مثبت" + }, + "previous_frames": { + "name": "فریم‌های قبلی" + }, + "start_image": { + "name": "تصویر شروع" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "عرض" + } + }, + "outputs": { + "0": { + "name": "مدل", + "tooltip": null + }, + "1": { + "name": "مثبت", + "tooltip": null + }, + "2": { + "name": "منفی", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "تصویر برش‌خورده", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16156,6 +16556,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "افزایش‌دهنده سریع و با کیفیت ویدیو که وضوح را افزایش داده و شفافیت را برای ویدیوهای کم‌کیفیت یا تار بازمی‌گرداند.", + "display_name": "افزایش کیفیت ویدیو FlashVSR", + "inputs": { + "target_resolution": { + "name": "وضوح هدف" + }, + "video": { + "name": "ویدیو" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "افزایش وضوح و کیفیت تصویر، ارتقاء عکس‌ها به ۴K یا ۸K برای نتایج شفاف و با جزئیات.", + "display_name": "افزایش کیفیت تصویر WaveSpeed", + "inputs": { + "image": { + "name": "تصویر" + }, + "model": { + "name": "مدل" + }, + "target_resolution": { + "name": "وضوح هدف" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "دریافت از وب‌کم", "inputs": { @@ -16168,8 +16605,7 @@ "image": { "name": "تصویر" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "عرض" } diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 68bbbc70d..ec3f81a53 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "نمایش جعبه ابزار انتخاب" + "name": "نمایش جعبه ابزار انتخاب", + "tooltip": "نمایش یک نوار ابزار شناور هنگام انتخاب nodeها، برای دسترسی سریع به اقدامات متداول." }, "Comfy_ConfirmClear": { "name": "نیاز به تأیید هنگام پاک‌سازی workflow" @@ -147,7 +148,8 @@ "Linear": "خطی", "Spline": "اسپلاین", "Straight": "مستقیم" - } + }, + "tooltip": "ظاهر و نمایش پیوندهای ارتباطی بین nodeها روی canvas را کنترل می‌کند." }, "Comfy_Load3D_3DViewerEnable": { "name": "فعال‌سازی نمایشگر سه‌بعدی (بتا)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "نمایش عرض × ارتفاع زیر پیش‌نمایش تصویر" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "نمایش همیشگی ابزارهای پیشرفته در همه نودها", + "tooltip": "در صورت فعال بودن، ابزارهای پیشرفته همیشه در همه نودها قابل مشاهده هستند و نیازی به باز کردن جداگانه آن‌ها نیست." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "اتصال خودکار لینک به اسلات نود", "tooltip": "هنگام کشیدن یک لینک روی نود، لینک به طور خودکار به ورودی مناسب روی نود متصل می‌شود." @@ -332,6 +338,10 @@ "name": "اندازه تاریخچه صف", "tooltip": "حداکثر تعداد taskهایی که در تاریخچه صف نمایش داده می‌شوند." }, + "Comfy_Queue_QPOV2": { + "name": "استفاده از صف کار یکپارچه در پنل کناری دارایی‌ها", + "tooltip": "پنل شناور صف کار را با صف کاری معادل که در پنل کناری دارایی‌ها قرار دارد جایگزین می‌کند. می‌توانید این گزینه را غیرفعال کنید تا به چیدمان پنل شناور بازگردید." + }, "Comfy_Sidebar_Location": { "name": "محل نوار کناری", "options": { @@ -466,6 +476,7 @@ "tooltip": "افست نقطه کنترل بزیه از نقطه مرکزی مسیر مجدد" }, "pysssss_SnapToGrid": { - "name": "همیشه چسباندن به شبکه" + "name": "همیشه چسباندن به شبکه", + "tooltip": "در صورت فعال بودن، nodeها هنگام جابجایی یا تغییر اندازه به طور خودکار با شبکه تراز می‌شوند." } } diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 014f2ea0e..78a66b351 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -24,6 +24,7 @@ "assets": "Ressources", "baseModels": "Modèles de base", "browseAssets": "Parcourir les ressources", + "byType": "Par type", "checkpoints": "Checkpoints", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "Exemple :", @@ -45,6 +46,10 @@ "failed": "Échec du téléchargement", "inProgress": "Téléchargement de {assetName}..." }, + "emptyImported": { + "canImport": "Aucun modèle importé pour le moment. Cliquez sur « Importer un modèle » pour ajouter le vôtre.", + "restricted": "Les modèles personnels sont disponibles uniquement à partir du niveau Creator." + }, "errorFileTooLarge": "Le fichier dépasse la taille maximale autorisée", "errorFormatNotAllowed": "Seul le format SafeTensor est autorisé", "errorModelTypeNotSupported": "Ce type de modèle n'est pas pris en charge", @@ -61,6 +66,7 @@ "finish": "Terminer", "genericLinkPlaceholder": "Collez le lien ici", "importAnother": "Importer un autre", + "imported": "Importé", "jobId": "ID de tâche", "loadingModels": "Chargement de {type}...", "maxFileSize": "Taille maximale du fichier : {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "Modèle 3D" }, "modelAssociatedWithLink": "Le modèle associé au lien que vous avez fourni :", + "modelInfo": { + "addBaseModel": "Ajouter un modèle de base...", + "addTag": "Ajouter un tag...", + "additionalTags": "Tags supplémentaires", + "baseModelUnknown": "Modèle de base inconnu", + "basicInfo": "Informations de base", + "compatibleBaseModels": "Modèles de base compatibles", + "description": "Description", + "descriptionNotSet": "Aucune description définie", + "descriptionPlaceholder": "Ajoutez une description pour ce modèle...", + "displayName": "Nom d'affichage", + "editDisplayName": "Modifier le nom affiché", + "fileName": "Nom du fichier", + "modelDescription": "Description du modèle", + "modelTagging": "Étiquetage du modèle", + "modelType": "Type de modèle", + "noAdditionalTags": "Aucun tag supplémentaire", + "selectModelPrompt": "Sélectionnez un modèle pour voir ses informations", + "selectModelType": "Sélectionner le type de modèle...", + "source": "Source", + "title": "Infos du modèle", + "triggerPhrases": "Phrases déclencheuses", + "viewOnSource": "Voir sur {source}" + }, "modelName": "Nom du modèle", "modelNamePlaceholder": "Entrez un nom pour ce modèle", "modelTypeSelectorLabel": "Quel type de modèle est-ce ?", @@ -238,6 +268,12 @@ "title": "Créer un compte" } }, + "boundingBox": { + "height": "Hauteur", + "width": "Largeur", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Effacer le workflow", "deleteBlueprint": "Supprimer le plan", @@ -678,6 +714,7 @@ "clearAll": "Tout effacer", "clearFilters": "Effacer les filtres", "close": "Fermer", + "closeDialog": "Fermer la boîte de dialogue", "color": "Couleur", "comfy": "Comfy", "comfyOrgLogoAlt": "Logo ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "contrôle avant génération", "copied": "Copié", "copy": "Copier", + "copyAll": "Tout copier", "copyJobId": "Copier l'ID du travail", "copyToClipboard": "Copier dans le presse-papiers", "copyURL": "Copier l’URL", @@ -756,6 +794,8 @@ "goToNode": "Aller au nœud", "graphNavigation": "Navigation dans le graphe", "halfSpeed": "0.5x", + "hideLeftPanel": "Masquer le panneau de gauche", + "hideRightPanel": "Masquer le panneau de droite", "icon": "Icône", "imageFailedToLoad": "Échec du chargement de l'image", "imagePreview": "Aperçu de l'image - Utilisez les flèches pour naviguer entre les images", @@ -797,6 +837,7 @@ "name": "Nom", "newFolder": "Nouveau dossier", "next": "Suivant", + "nightly": "NIGHTLY", "no": "Non", "noAudioRecorded": "Aucun audio enregistré", "noItems": "Aucun élément", @@ -811,6 +852,7 @@ "nodeSlotsError": "Erreur d'emplacements du nœud", "nodeWidgetsError": "Erreur de widgets du nœud", "nodes": "Nœuds", + "nodesCount": "{count} nœuds | {count} nœud | {count} nœuds", "nodesRunning": "nœuds en cours d’exécution", "none": "Aucun", "nothingToCopy": "Rien à copier", @@ -885,7 +927,9 @@ "selectedFile": "Fichier sélectionné", "setAsBackground": "Définir comme arrière-plan", "settings": "Paramètres", + "showLeftPanel": "Afficher le panneau de gauche", "showReport": "Afficher le rapport", + "showRightPanel": "Afficher le panneau de droite", "singleSelectDropdown": "Menu déroulant à sélection unique", "sort": "Trier", "source": "Source", @@ -908,6 +952,7 @@ "updating": "Mise à jour", "upload": "Téléverser", "usageHint": "Conseil d'utilisation", + "use": "Utiliser", "user": "Utilisateur", "versionMismatchWarning": "Avertissement de compatibilité de version", "versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.", @@ -915,11 +960,10 @@ "videoPreview": "Aperçu de la vidéo - Utilisez les flèches pour naviguer entre les vidéos", "viewImageOfTotal": "Voir l'image {index} sur {total}", "viewVideoOfTotal": "Voir la vidéo {index} sur {total}", - "vitePreloadErrorMessage": "Une nouvelle version de l'application a été publiée. Souhaitez-vous recharger ?\nSi vous refusez, certaines parties de l'application pourraient ne pas fonctionner correctement.\nN'hésitez pas à refuser et à sauvegarder votre progression avant de recharger.", - "vitePreloadErrorTitle": "Nouvelle version disponible", "volume": "Volume", "warning": "Avertissement", - "workflow": "Flux de travail" + "workflow": "Flux de travail", + "you": "Vous" }, "graphCanvasMenu": { "fitView": "Adapter la vue", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "Aucune image à comparer" }, + "imageCrop": { + "cropPreviewAlt": "Aperçu du recadrage", + "loading": "Chargement...", + "noInputImage": "Aucune image d'entrée connectée" + }, "importFailed": { "copyError": "Erreur de copie", "title": "Échec de l’importation" @@ -1606,17 +1655,25 @@ "title": "Ce flux de travail a des nœuds manquants" } }, + "nightly": { + "badge": { + "label": "Version de prévisualisation", + "tooltip": "Vous utilisez une version nightly de ComfyUI. Veuillez utiliser le bouton de retour pour partager vos impressions sur ces fonctionnalités." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "modèles_3d", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_pour_test", "advanced": "avancé", "animation": "animation", @@ -1841,6 +1900,7 @@ }, "groupSettings": "Paramètres du groupe", "groups": "Groupes", + "hideAdvancedInputsButton": "Masquer les entrées avancées", "hideInput": "Masquer l’entrée", "info": "Infos", "inputs": "ENTRÉES", @@ -2073,6 +2133,7 @@ "NodeLibrary": "Bibliothèque de Nœuds", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "Préférences de notification", + "Other": "Autre", "PLY": "PLY", "PlanCredits": "Forfait et crédits", "Pointer": "Pointeur", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Nœuds Vue", "VueNodes": "Nœuds Vue", "Window": "Fenêtre", - "Workflow": "Flux de Travail" + "Workflow": "Flux de Travail", + "Workspace": "Espace de travail" }, "shape": { "CARD": "Carte", @@ -2118,12 +2180,14 @@ "viewControls": "Contrôles d'affichage" }, "sideToolbar": { + "activeJobStatus": "Tâche active : {status}", "assets": "Ressources", "backToAssets": "Retour à toutes les ressources", "browseTemplates": "Parcourir les modèles d'exemple", "downloads": "Téléchargements", "generatedAssetsHeader": "Ressources générées", "helpCenter": "Centre d'aide", + "importedAssetsHeader": "Ressources importées", "labels": { "assets": "Ressources", "console": "Console", @@ -2168,6 +2232,7 @@ "queue": "File d'attente", "queueProgressOverlay": { "activeJobs": "{count} travail actif | {count} travaux actifs", + "activeJobsShort": "{count} actif(s) | {count} actif(s)", "activeJobsSuffix": "travaux actifs", "cancelJobTooltip": "Annuler le travail", "clearHistory": "Effacer l’historique de la file d’attente", @@ -2256,9 +2321,15 @@ "beta": "BÊTA", "billedMonthly": "Facturé mensuellement", "billedYearly": "{total} facturé annuellement", + "billingComingSoon": { + "message": "La facturation d'équipe arrive bientôt. Vous pourrez souscrire à un abonnement pour votre espace de travail avec un tarif par utilisateur. Restez à l'écoute pour les mises à jour.", + "title": "Bientôt disponible" + }, + "cancelSubscription": "Annuler l’abonnement", "changeTo": "Changer pour {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo Comfy Cloud", + "contactOwnerToSubscribe": "Contactez le propriétaire de l’espace de travail pour vous abonner", "contactUs": "Contactez-nous", "creditsRemainingThisMonth": "Crédits restants ce mois-ci", "creditsRemainingThisYear": "Crédits restants cette année", @@ -2271,6 +2342,7 @@ "haveQuestions": "Des questions ou besoin d'une offre entreprise ?", "invoiceHistory": "Historique des factures", "learnMore": "En savoir plus", + "managePayment": "Gérer le paiement", "managePlan": "Gérer le forfait", "manageSubscription": "Gérer l'abonnement", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "S'abonner à Comfy Cloud", "subscribeToRun": "S'abonner", "subscribeToRunFull": "S'abonner pour exécuter", + "subscriptionRequiredMessage": "Un abonnement est requis pour que les membres puissent exécuter des workflows sur le Cloud", "tierNameYearly": "{name} Annuel", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "Voir plus de détails", "viewMoreDetailsPlans": "Voir plus de détails sur les forfaits et tarifs", "viewUsageHistory": "Voir l'historique d'utilisation", + "workspaceNotSubscribed": "Cet espace de travail n’a pas d’abonnement", "yearly": "Annuel", "yearlyCreditsLabel": "Crédits annuels totaux", "yearlyDiscount": "20% DE RÉDUCTION", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "Échec du chargement du modèle 3D", "failedToPurchaseCredits": "Échec de l'achat de crédits : {error}", "failedToQueue": "Échec de la mise en file d'attente", + "failedToSaveDraft": "Échec de l’enregistrement du brouillon du flux de travail", "failedToToggleCamera": "Échec de l’activation/désactivation de la caméra", "failedToToggleGrid": "Échec de l’activation/désactivation de la grille", "failedToUpdateBackgroundColor": "Échec de la mise à jour de la couleur d’arrière-plan", @@ -2486,7 +2561,8 @@ "notSet": "Non défini", "provider": "Méthode de connexion", "title": "Paramètres utilisateur", - "updatePassword": "Mettre à jour le mot de passe" + "updatePassword": "Mettre à jour le mot de passe", + "workspaceSettings": "Paramètres de l’espace de travail" }, "validation": { "descriptionRequired": "La description est requise", @@ -2577,6 +2653,9 @@ "saveWorkflow": "Enregistrer le flux de travail" }, "workspace": { + "addedToWorkspace": "Vous avez été ajouté à {workspaceName}", + "inviteAccepted": "Invitation acceptée", + "inviteFailed": "Échec de l'acceptation de l'invitation", "unsavedChanges": { "message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer d’espace de travail ?", "title": "Modifications non enregistrées" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "Espace de travail introuvable" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "Créer", + "message": "Les espaces de travail permettent aux membres de partager un même pool de crédits. Vous deviendrez le propriétaire après la création.", + "nameLabel": "Nom de l’espace de travail*", + "namePlaceholder": "Saisissez le nom de l’espace de travail", + "title": "Créer un nouvel espace de travail" + }, + "dashboard": { + "placeholder": "Paramètres de l'espace de travail du tableau de bord" + }, + "deleteDialog": { + "message": "Tout crédit inutilisé ou ressource non enregistrée sera perdu. Cette action est irréversible.", + "messageWithName": "Supprimer « {name} » ? Tout crédit inutilisé ou ressource non enregistrée sera perdu. Cette action est irréversible.", + "title": "Supprimer cet espace de travail ?" + }, + "editWorkspaceDialog": { + "nameLabel": "Nom de l’espace de travail", + "save": "Enregistrer", + "title": "Modifier les détails de l’espace de travail" + }, + "invite": "Inviter", + "inviteLimitReached": "Vous avez atteint le maximum de 50 membres", + "inviteMember": "Inviter un membre", + "inviteMemberDialog": { + "createLink": "Créer le lien", + "linkCopied": "Copié", + "linkCopyFailed": "Échec de la copie du lien", + "linkStep": { + "copyLink": "Copier le lien", + "done": "Terminé", + "message": "Assurez-vous que son compte utilise cet email.", + "title": "Envoyez ce lien à la personne" + }, + "message": "Créez un lien d'invitation partageable à envoyer à quelqu'un", + "placeholder": "Entrez l'email de la personne", + "title": "Inviter une personne dans cet espace de travail" + }, + "leaveDialog": { + "leave": "Quitter", + "message": "Vous ne pourrez pas le rejoindre à nouveau sans contacter le propriétaire de l’espace de travail.", + "title": "Quitter cet espace de travail ?" + }, + "members": { + "actions": { + "copyLink": "Copier le lien d'invitation", + "removeMember": "Retirer le membre", + "revokeInvite": "Révoquer l'invitation" + }, + "columns": { + "expiryDate": "Date d'expiration", + "inviteDate": "Date d'invitation", + "joinDate": "Date d'adhésion" + }, + "createNewWorkspace": "créez-en un nouveau.", + "membersCount": "{count}/50 membres", + "noInvites": "Aucune invitation en attente", + "noMembers": "Aucun membre", + "pendingInvitesCount": "{count} invitation en attente | {count} invitations en attente", + "personalWorkspaceMessage": "Vous ne pouvez pas inviter d'autres membres dans votre espace de travail personnel pour le moment. Pour ajouter des membres à un espace de travail,", + "tabs": { + "active": "Actif", + "pendingCount": "En attente ({count})" + } + }, + "menu": { + "deleteWorkspace": "Supprimer l’espace de travail", + "deleteWorkspaceDisabledTooltip": "Annulez d’abord l’abonnement actif de votre espace de travail", + "editWorkspace": "Modifier les détails de l’espace de travail", + "leaveWorkspace": "Quitter l’espace de travail" + }, + "removeMemberDialog": { + "error": "Échec du retrait du membre", + "message": "Ce membre sera retiré de votre espace de travail. Les crédits qu'il a utilisés ne seront pas remboursés.", + "remove": "Retirer le membre", + "success": "Membre retiré", + "title": "Retirer ce membre ?" + }, + "revokeInviteDialog": { + "message": "Cette personne ne pourra plus rejoindre votre espace de travail. Son lien d'invitation sera invalidé.", + "revoke": "Annuler l'invitation", + "title": "Annuler l'invitation de cette personne ?" + }, + "tabs": { + "dashboard": "Tableau de bord", + "membersCount": "Membres ({count})", + "planCredits": "Forfait & Crédits" + }, + "toast": { + "failedToCreateWorkspace": "Échec de la création de l’espace de travail", + "failedToDeleteWorkspace": "Échec de la suppression de l’espace de travail", + "failedToFetchWorkspaces": "Échec du chargement des espaces de travail", + "failedToLeaveWorkspace": "Échec de la sortie de l’espace de travail", + "failedToUpdateWorkspace": "Échec de la mise à jour de l’espace de travail", + "workspaceCreated": { + "message": "Abonnez-vous à un plan, invitez des coéquipiers et commencez à collaborer.", + "subscribe": "S'abonner", + "title": "Espace de travail créé" + }, + "workspaceDeleted": { + "message": "L'espace de travail a été définitivement supprimé.", + "title": "Espace de travail supprimé" + }, + "workspaceLeft": { + "message": "Vous avez quitté l'espace de travail.", + "title": "Espace de travail quitté" + }, + "workspaceUpdated": { + "message": "Les détails de l’espace de travail ont été enregistrés.", + "title": "Espace de travail mis à jour" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "Créer un nouvel espace de travail", + "maxWorkspacesReached": "Vous ne pouvez posséder que 10 espaces de travail. Supprimez-en un pour en créer un nouveau.", + "personal": "Personnel", + "roleMember": "Membre", + "roleOwner": "Propriétaire", + "subscribe": "S’abonner", + "switchWorkspace": "Changer d’espace de travail" + }, "zoomControls": { "hideMinimap": "Masquer la mini-carte", "label": "Contrôles de zoom", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index f7c51cde4..316aaa14f 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Modifiez des images en utilisant le dernier modèle Bria", + "display_name": "Bria Image Edit", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "guidance_scale": { + "name": "échelle de guidage", + "tooltip": "Une valeur plus élevée fait suivre l'image à l'invite de façon plus précise." + }, + "image": { + "name": "image" + }, + "mask": { + "name": "masque", + "tooltip": "Si omis, la modification s'applique à l'image entière." + }, + "model": { + "name": "modèle" + }, + "moderation": { + "name": "modération", + "tooltip": "Paramètres de modération" + }, + "moderation_prompt_content_moderation": { + "name": "modération_du_contenu_de_l'invite" + }, + "moderation_visual_input_moderation": { + "name": "modération_de_l'entrée_visuelle" + }, + "moderation_visual_output_moderation": { + "name": "modération_de_la_sortie_visuelle" + }, + "negative_prompt": { + "name": "invite négative" + }, + "prompt": { + "name": "invite", + "tooltip": "Instruction pour modifier l'image" + }, + "seed": { + "name": "graine" + }, + "steps": { + "name": "étapes" + }, + "structured_prompt": { + "name": "invite structurée", + "tooltip": "Une chaîne contenant l'invite d'édition structurée au format JSON. Utilisez ceci à la place de l'invite habituelle pour un contrôle précis et programmatique." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "invite structurée", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Générer une vidéo en utilisant l'invite et les première et dernière images.", "display_name": "ByteDance Première-Dernière Image vers Vidéo", @@ -351,6 +413,10 @@ "name": "première_image", "tooltip": "Première image à utiliser pour la vidéo." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "last_frame": { "name": "dernière_image", "tooltip": "Dernière image à utiliser pour la vidéo." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "Modifier des images en utilisant les modèles ByteDance via l'API basée sur le prompt", - "display_name": "Édition d'image ByteDance", - "inputs": { - "control_after_generate": { - "name": "control after generate" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "Une valeur plus élevée fait que l'image suit plus fidèlement le prompt" - }, - "image": { - "name": "image", - "tooltip": "L'image de base à modifier" - }, - "model": { - "name": "model" - }, - "prompt": { - "name": "prompt", - "tooltip": "Instruction pour modifier l'image" - }, - "seed": { - "name": "seed", - "tooltip": "Graine à utiliser pour la génération." - }, - "watermark": { - "name": "watermark", - "tooltip": "Indique s'il faut ajouter un filigrane \"Généré par IA\" à l'image" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "Générer des images en utilisant les modèles ByteDance via l'API basée sur le prompt", "display_name": "Image ByteDance", @@ -527,6 +556,10 @@ "name": "durée", "tooltip": "La durée de la vidéo en sortie en secondes." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "Première image à utiliser pour la vidéo." @@ -634,6 +667,10 @@ "name": "duration", "tooltip": "La durée de la vidéo de sortie en secondes." }, + "generate_audio": { + "name": "générer_audio", + "tooltip": "Ce paramètre est ignoré pour tout modèle sauf seedance-1-5-pro." + }, "model": { "name": "model" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "choix" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "INDEX", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "Charger le modèle ControlNet (diff)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "Charger 3D", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "hauteur" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "fichier_modèle" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "largeur" } @@ -6270,13 +6306,11 @@ "description": "Chargez une image à partir du dossier de sortie. Lorsque le bouton de rafraîchissement est cliqué, le nœud mettra à jour la liste des images et sélectionnera automatiquement la première image, permettant une itération facile.", "display_name": "Charger l'image (à partir des sorties)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "image" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "choisissez le fichier à télécharger" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "Appliquer LoRA en mode bypass. Contrairement à LoRA classique, cela ne modifie pas les poids du modèle - à la place, le calcul LoRA est injecté lors du passage avant. Utile pour les scénarios d'entraînement.", + "display_name": "Charger LoRA (Bypass) (Pour le débogage)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "Le modèle CLIP auquel le LoRA sera appliqué." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "Le nom du LoRA." + }, + "model": { + "name": "model", + "tooltip": "Le modèle de diffusion auquel le LoRA sera appliqué." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "Intensité de modification du modèle CLIP. Cette valeur peut être négative." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "Intensité de modification du modèle de diffusion. Cette valeur peut être négative." + } + }, + "outputs": { + "0": { + "tooltip": "Le modèle de diffusion modifié." + }, + "1": { + "tooltip": "Le modèle CLIP modifié." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "Appliquer LoRA en mode bypass. Contrairement à LoRA classique, cela ne modifie pas les poids du modèle - à la place, le calcul LoRA est injecté lors du passage avant. Utile pour les scénarios d'entraînement.", + "display_name": "Charger LoRA (Bypass, Modèle Seulement) (pour le débogage)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "Le modèle de diffusion modifié." + } + } + }, "LoraLoaderModelOnly": { "description": "Les LoRAs sont utilisés pour modifier les modèles de diffusion et CLIP, modifiant la manière dont les latents sont débruités comme l'application de styles. Plusieurs nœuds LoRA peuvent être liés ensemble.", "display_name": "LoraLoaderModelOnly", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "Reluminez une image avec des ajustements de lumière et un transfert de lumière basé sur une référence en option.", + "display_name": "Magnific Relumination d’Image", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Options de réglage fin pour un contrôle avancé de l’éclairage." + }, + "change_background": { + "name": "change_background", + "tooltip": "Modifie l’arrière-plan selon le prompt/la référence." + }, + "image": { + "name": "image", + "tooltip": "L’image à reluminer." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Restreint la liberté de génération pour correspondre davantage à l’original." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Intensité de l’application du transfert de lumière." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Préserve la texture et les détails fins de l’original." + }, + "prompt": { + "name": "prompt", + "tooltip": "Instructions descriptives pour l’éclairage. Prend en charge la notation d’emphase (1-1.4)." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Image de référence optionnelle pour transférer la lumière." + }, + "style": { + "name": "style", + "tooltip": "Préférence stylistique de sortie." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "Amélioration de la peau pour les portraits avec plusieurs modes de traitement.", + "display_name": "Magnific Améliorateur de Peau d’Image", + "inputs": { + "image": { + "name": "image", + "tooltip": "Le portrait à améliorer." + }, + "mode": { + "name": "mode", + "tooltip": "Mode de traitement : créatif pour une amélioration artistique, fidèle pour préserver l’apparence originale, flexible pour une optimisation ciblée." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Niveau d’intensité de la netteté." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Niveau d’intensité du grain intelligent." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "Transférez le style d'une image de référence vers votre image d'entrée.", + "display_name": "Transfert de style d'image Magnific", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "Sélection du moteur de traitement." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "Lorsque désactivé, chaque génération introduit un certain degré d'aléatoire, produisant des résultats plus variés." + }, + "flavor": { + "name": "flavor", + "tooltip": "Type de transfert de style." + }, + "image": { + "name": "image", + "tooltip": "L'image à laquelle appliquer le transfert de style." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Activer le mode portrait pour les améliorations du visage." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "L'image de référence dont extraire le style." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Préserve la structure de l'image originale." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Pourcentage d'intensité du style." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "Modifiez le guidage pour qu'il se concentre davantage sur la 'direction' de l'invite positive plutôt que sur la différence avec l'invite négative.", "display_name": "Mahiro est si mignonne qu'elle mérite une meilleure fonction de guidage!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "Aperçu de n'importe quel", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "source" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "Redimensionner une image ou un mask en utilisant différentes méthodes de mise à l'échelle.", "display_name": "Redimensionner image/masque", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "Sélectionnez la méthode de redimensionnement : par dimensions exactes, facteur d'échelle, correspondance avec une autre image, etc." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "rogner" + }, + "resize_type_height": { + "name": "hauteur" + }, + "resize_type_width": { + "name": "largeur" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "Algorithme d'interpolation. 'area' est optimal pour la réduction, 'lanczos' pour l'agrandissement, 'nearest-exact' pour le pixel art." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D : Image(s) vers Modèle (Pro)", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "face_count": { + "name": "nombre de faces" + }, + "generate_type": { + "name": "type de génération" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "image" + }, + "image_back": { + "name": "image arrière" + }, + "image_left": { + "name": "image gauche" + }, + "image_right": { + "name": "image droite" + }, + "model": { + "name": "modèle", + "tooltip": "L’option LowPoly n’est pas disponible pour le modèle `3.1`." + }, + "seed": { + "name": "graine", + "tooltip": "La graine contrôle si le nœud doit être relancé ; les résultats restent non déterministes quelle que soit la graine." + } + }, + "outputs": { + "0": { + "name": "fichier_modèle", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D : Texte vers Modèle (Pro)", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "face_count": { + "name": "nombre de faces" + }, + "generate_type": { + "name": "type de génération" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "modèle", + "tooltip": "L’option LowPoly n’est pas disponible pour le modèle `3.1`." + }, + "prompt": { + "name": "invite", + "tooltip": "Jusqu’à 1024 caractères pris en charge." + }, + "seed": { + "name": "graine", + "tooltip": "La graine contrôle si le nœud doit être relancé ; les résultats restent non déterministes quelle que soit la graine." + } + }, + "outputs": { + "0": { + "name": "fichier_modèle", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "redimensionnement automatique des images" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "image1" + }, + "image2": { + "name": "image2" + }, + "image3": { + "name": "image3" + }, + "image_encoder": { + "name": "encodeur d'image" + }, + "prompt": { + "name": "invite" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Texte en minuscules", "inputs": { @@ -13643,6 +13970,10 @@ "name": "bucket_mode", "tooltip": "Activer le mode de résolution par compartiments. Lorsqu’il est activé, attend des latents pré-triés depuis le nœud ResolutionBucket." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Activer le mode bypass pour l'entraînement. Lorsqu'il est activé, les adaptateurs sont appliqués via des hooks forward au lieu de la modification des poids. Utile pour les modèles quantifiés dont les poids ne peuvent pas être modifiés directement." + }, "control_after_generate": { "name": "contrôle après génération" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "sortie encodeur audio 1" + }, + "audio_scale": { + "name": "échelle audio" + }, + "clip_vision_output": { + "name": "sortie vision clip" + }, + "height": { + "name": "hauteur" + }, + "length": { + "name": "longueur" + }, + "mode": { + "name": "mode" + }, + "model": { + "name": "modèle" + }, + "model_patch": { + "name": "correctif du modèle" + }, + "motion_frame_count": { + "name": "nombre d’images de mouvement", + "tooltip": "Nombre d’images précédentes à utiliser comme contexte de mouvement." + }, + "negative": { + "name": "négatif" + }, + "positive": { + "name": "positif" + }, + "previous_frames": { + "name": "images précédentes" + }, + "start_image": { + "name": "image de départ" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "largeur" + } + }, + "outputs": { + "0": { + "name": "modèle", + "tooltip": null + }, + "1": { + "name": "positif", + "tooltip": null + }, + "2": { + "name": "négatif", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "image rognée", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "Upscaler vidéo rapide et de haute qualité qui augmente la résolution et restaure la clarté des séquences basse résolution ou floues.", + "display_name": "FlashVSR Upscale Vidéo", + "inputs": { + "target_resolution": { + "name": "résolution cible" + }, + "video": { + "name": "vidéo" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "Augmentez la résolution et la qualité de l'image, en upscalant les photos en 4K ou 8K pour des résultats nets et détaillés.", + "display_name": "WaveSpeed Upscale Image", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "modèle" + }, + "target_resolution": { + "name": "résolution cible" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Capture Webcam", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "image" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "largeur" } diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index 3f5b6d01b..1847879d3 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Afficher la boîte à outils de sélection" + "name": "Afficher la boîte à outils de sélection", + "tooltip": "Affiche une barre d’outils flottante lorsque des nœuds sont sélectionnés, offrant un accès rapide aux actions courantes." }, "Comfy_ConfirmClear": { "name": "Demander une confirmation lors de l'effacement du flux de travail" @@ -147,7 +148,8 @@ "Linear": "Linéaire", "Spline": "Spline", "Straight": "Droit" - } + }, + "tooltip": "Contrôle l’apparence et la visibilité des liens de connexion entre les nœuds sur le canevas." }, "Comfy_Load3D_3DViewerEnable": { "name": "Activer le visualiseur 3D (Bêta)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Afficher la largeur × la hauteur sous l'aperçu de l'image" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Toujours afficher les widgets avancés sur tous les nœuds", + "tooltip": "Lorsque cette option est activée, les widgets avancés sont toujours visibles sur tous les nœuds sans avoir besoin de les développer individuellement." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Lien d'ancrage automatique à l'emplacement du nœud", "tooltip": "Lorsque vous faites glisser un lien sur un nœud, le lien se fixe automatiquement à une fente d'entrée viable sur le nœud" @@ -332,6 +338,10 @@ "name": "Taille de l'historique de la file d'attente", "tooltip": "Le nombre maximum de tâches qui s'affichent dans l'historique de la file d'attente." }, + "Comfy_Queue_QPOV2": { + "name": "Utiliser la file d’attente unifiée dans le panneau latéral des ressources", + "tooltip": "Remplace le panneau flottant de la file d’attente des tâches par une file d’attente équivalente intégrée dans le panneau latéral des ressources. Vous pouvez désactiver cette option pour revenir à la disposition du panneau flottant." + }, "Comfy_Sidebar_Location": { "name": "Emplacement de la barre latérale", "options": { @@ -466,6 +476,7 @@ "tooltip": "Le point de contrôle de Bézier est décalé par rapport au point central de réacheminement" }, "pysssss_SnapToGrid": { - "name": "Toujours aligner sur la grille" + "name": "Toujours aligner sur la grille", + "tooltip": "Lorsque cette option est activée, les nœuds s’alignent automatiquement sur la grille lors de leur déplacement ou redimensionnement." } } diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 7791475f5..536ea7a1f 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -24,6 +24,7 @@ "assets": "アセット", "baseModels": "ベースモデル", "browseAssets": "アセットを閲覧", + "byType": "タイプ別", "checkpoints": "チェックポイント", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "例:", @@ -45,6 +46,10 @@ "failed": "ダウンロードに失敗しました", "inProgress": "{assetName}をダウンロード中..." }, + "emptyImported": { + "canImport": "まだインポートされたモデルはありません。「モデルをインポート」をクリックして追加してください。", + "restricted": "パーソナルモデルはCreator以上のプランでのみ利用可能です。" + }, "errorFileTooLarge": "ファイルが許可された最大サイズを超えています", "errorFormatNotAllowed": "SafeTensor形式のみ許可されています", "errorModelTypeNotSupported": "このモデルタイプはサポートされていません", @@ -61,6 +66,7 @@ "finish": "完了", "genericLinkPlaceholder": "ここにリンクを貼り付けてください", "importAnother": "別のファイルをインポート", + "imported": "インポート済み", "jobId": "ジョブID", "loadingModels": "{type}を読み込み中...", "maxFileSize": "最大ファイルサイズ:{size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3Dモデル" }, "modelAssociatedWithLink": "ご提供いただいたリンクに関連付けられているモデル:", + "modelInfo": { + "addBaseModel": "ベースモデルを追加...", + "addTag": "タグを追加...", + "additionalTags": "追加タグ", + "baseModelUnknown": "ベースモデル不明", + "basicInfo": "基本情報", + "compatibleBaseModels": "互換性のあるベースモデル", + "description": "説明", + "descriptionNotSet": "説明が設定されていません", + "descriptionPlaceholder": "このモデルの説明を追加...", + "displayName": "表示名", + "editDisplayName": "表示名を編集", + "fileName": "ファイル名", + "modelDescription": "モデル説明", + "modelTagging": "モデルタグ付け", + "modelType": "モデルタイプ", + "noAdditionalTags": "追加タグなし", + "selectModelPrompt": "モデルを選択して情報を表示してください", + "selectModelType": "モデルタイプを選択...", + "source": "ソース", + "title": "モデル情報", + "triggerPhrases": "トリガーフレーズ", + "viewOnSource": "{source} で表示" + }, "modelName": "モデル名", "modelNamePlaceholder": "このモデルの名前を入力してください", "modelTypeSelectorLabel": "モデルの種類は何ですか?", @@ -238,6 +268,12 @@ "title": "アカウントを作成する" } }, + "boundingBox": { + "height": "高さ", + "width": "幅", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "ワークフローをクリア", "deleteBlueprint": "ブループリントを削除", @@ -678,6 +714,7 @@ "clearAll": "すべてクリア", "clearFilters": "フィルターをクリア", "close": "閉じる", + "closeDialog": "ダイアログを閉じる", "color": "色", "comfy": "Comfy", "comfyOrgLogoAlt": "ComfyOrgロゴ", @@ -694,6 +731,7 @@ "control_before_generate": "生成前の制御", "copied": "コピーしました", "copy": "コピー", + "copyAll": "すべてコピー", "copyJobId": "ジョブIDをコピー", "copyToClipboard": "クリップボードにコピー", "copyURL": "URLをコピー", @@ -756,6 +794,8 @@ "goToNode": "ノードに移動", "graphNavigation": "グラフナビゲーション", "halfSpeed": "0.5倍速", + "hideLeftPanel": "左パネルを非表示", + "hideRightPanel": "右パネルを非表示", "icon": "アイコン", "imageFailedToLoad": "画像の読み込みに失敗しました", "imagePreview": "画像プレビュー - 矢印キーで画像を切り替え", @@ -797,6 +837,7 @@ "name": "名前", "newFolder": "新しいフォルダー", "next": "次へ", + "nightly": "NIGHTLY", "no": "いいえ", "noAudioRecorded": "音声が録音されていません", "noItems": "項目がありません", @@ -811,6 +852,7 @@ "nodeSlotsError": "ノードスロットエラー", "nodeWidgetsError": "ノードウィジェットエラー", "nodes": "ノード", + "nodesCount": "{count} ノード | {count} ノード | {count} ノード", "nodesRunning": "ノードが実行中", "none": "なし", "nothingToCopy": "コピーするものがありません", @@ -885,7 +927,9 @@ "selectedFile": "選択されたファイル", "setAsBackground": "背景として設定", "settings": "設定", + "showLeftPanel": "左パネルを表示", "showReport": "レポートを表示", + "showRightPanel": "右パネルを表示", "singleSelectDropdown": "単一選択ドロップダウン", "sort": "並び替え", "source": "ソース", @@ -908,6 +952,7 @@ "updating": "更新中", "upload": "アップロード", "usageHint": "使用ヒント", + "use": "使用", "user": "ユーザー", "versionMismatchWarning": "バージョン互換性の警告", "versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。", @@ -915,11 +960,10 @@ "videoPreview": "ビデオプレビュー - 矢印キーでビデオを切り替え", "viewImageOfTotal": "画像 {index} / {total} を表示", "viewVideoOfTotal": "ビデオ {index} / {total} を表示", - "vitePreloadErrorMessage": "アプリの新しいバージョンがリリースされました。再読み込みしますか?\n再読み込みしない場合、アプリの一部が正しく動作しない可能性があります。\n再読み込み前に進行状況を保存してから拒否することもできます。", - "vitePreloadErrorTitle": "新しいバージョンが利用可能", "volume": "音量", "warning": "警告", - "workflow": "ワークフロー" + "workflow": "ワークフロー", + "you": "あなた" }, "graphCanvasMenu": { "fitView": "ビューに合わせる", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "比較する画像がありません" }, + "imageCrop": { + "cropPreviewAlt": "切り抜きプレビュー", + "loading": "読み込み中...", + "noInputImage": "入力画像が接続されていません" + }, "importFailed": { "copyError": "コピーエラー", "title": "インポート失敗" @@ -1606,17 +1655,25 @@ "title": "このワークフローには不足しているノードがあります" } }, + "nightly": { + "badge": { + "label": "プレビュー版", + "tooltip": "現在、ComfyUI のナイトリーバージョンを使用しています。これらの機能についてご意見があれば、フィードバックボタンからお知らせください。" + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "3Dモデル", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_テスト用", "advanced": "高度な機能", "animation": "アニメーション", @@ -1841,6 +1900,7 @@ }, "groupSettings": "グループ設定", "groups": "グループ", + "hideAdvancedInputsButton": "詳細入力を非表示", "hideInput": "入力を非表示", "info": "情報", "inputs": "入力", @@ -2073,6 +2133,7 @@ "NodeLibrary": "ノードライブラリ", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "通知設定", + "Other": "その他", "PLY": "PLY", "PlanCredits": "プランとクレジット", "Pointer": "ポインタ", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Vueノード", "VueNodes": "Vueノード", "Window": "ウィンドウ", - "Workflow": "ワークフロー" + "Workflow": "ワークフロー", + "Workspace": "ワークスペース" }, "shape": { "CARD": "カード", @@ -2118,12 +2180,14 @@ "viewControls": "ビューコントロール" }, "sideToolbar": { + "activeJobStatus": "アクティブジョブ: {status}", "assets": "アセット", "backToAssets": "すべてのアセットに戻る", "browseTemplates": "サンプルテンプレートを表示", "downloads": "ダウンロード", "generatedAssetsHeader": "生成されたアセット", "helpCenter": "ヘルプセンター", + "importedAssetsHeader": "インポート済みアセット", "labels": { "assets": "アセット", "console": "コンソール", @@ -2168,6 +2232,7 @@ "queue": "キュー", "queueProgressOverlay": { "activeJobs": "{count}件のアクティブジョブ", + "activeJobsShort": "{count} 件のアクティブ | {count} 件のアクティブ", "activeJobsSuffix": "アクティブジョブ", "cancelJobTooltip": "ジョブをキャンセル", "clearHistory": "ジョブキュー履歴をクリア", @@ -2256,9 +2321,15 @@ "beta": "ベータ版", "billedMonthly": "毎月請求", "billedYearly": "{total} 年間請求", + "billingComingSoon": { + "message": "チーム向けの請求機能が近日中に追加されます。ワークスペースごとに席数単位でプランに加入できるようになります。今後のアップデートをお待ちください。", + "title": "近日公開" + }, + "cancelSubscription": "サブスクリプションをキャンセル", "changeTo": "{plan}に変更", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud ロゴ", + "contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください", "contactUs": "お問い合わせ", "creditsRemainingThisMonth": "今月残りのクレジット", "creditsRemainingThisYear": "今年残りのクレジット", @@ -2271,6 +2342,7 @@ "haveQuestions": "ご質問やエンタープライズについてのお問い合わせはこちら", "invoiceHistory": "請求履歴", "learnMore": "詳細を見る", + "managePayment": "支払いを管理", "managePlan": "プランを管理", "manageSubscription": "サブスクリプションを管理", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "Comfy Cloudを購読", "subscribeToRun": "購読する", "subscribeToRunFull": "実行を購読", + "subscriptionRequiredMessage": "クラウドでワークフローを実行するにはメンバーにサブスクリプションが必要です", "tierNameYearly": "{name} 年間", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "詳細を表示", "viewMoreDetailsPlans": "プランと価格の詳細を見る", "viewUsageHistory": "利用履歴を表示", + "workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません", "yearly": "年額", "yearlyCreditsLabel": "年間合計クレジット", "yearlyDiscount": "20%割引", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "3Dモデルの読み込みに失敗しました", "failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}", "failedToQueue": "キューに追加できませんでした", + "failedToSaveDraft": "ワークフロードラフトの保存に失敗しました", "failedToToggleCamera": "カメラの切り替えに失敗しました", "failedToToggleGrid": "グリッドの切り替えに失敗しました", "failedToUpdateBackgroundColor": "背景色の更新に失敗しました", @@ -2486,7 +2561,8 @@ "notSet": "未設定", "provider": "サインイン方法", "title": "ユーザー設定", - "updatePassword": "パスワードを更新" + "updatePassword": "パスワードを更新", + "workspaceSettings": "ワークスペース設定" }, "validation": { "descriptionRequired": "説明が必要です", @@ -2577,6 +2653,9 @@ "saveWorkflow": "ワークフローを保存" }, "workspace": { + "addedToWorkspace": "{workspaceName}に追加されました", + "inviteAccepted": "招待を承諾しました", + "inviteFailed": "招待の承諾に失敗しました", "unsavedChanges": { "message": "未保存の変更があります。破棄してワークスペースを切り替えますか?", "title": "未保存の変更" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "ワークスペースが見つかりません" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "作成", + "message": "ワークスペースはメンバーでクレジットプールを共有できます。作成後、あなたがオーナーになります。", + "nameLabel": "ワークスペース名*", + "namePlaceholder": "ワークスペース名を入力", + "title": "新しいワークスペースを作成" + }, + "dashboard": { + "placeholder": "ダッシュボードのワークスペース設定" + }, + "deleteDialog": { + "message": "未使用のクレジットや保存されていないアセットは失われます。この操作は元に戻せません。", + "messageWithName": "「{name}」を削除しますか?未使用のクレジットや保存されていないアセットは失われます。この操作は元に戻せません。", + "title": "このワークスペースを削除しますか?" + }, + "editWorkspaceDialog": { + "nameLabel": "ワークスペース名", + "save": "保存", + "title": "ワークスペースの詳細を編集" + }, + "invite": "招待", + "inviteLimitReached": "メンバーの上限50人に達しました", + "inviteMember": "メンバーを招待", + "inviteMemberDialog": { + "createLink": "リンクを作成", + "linkCopied": "コピーしました", + "linkCopyFailed": "コピーに失敗しました", + "linkStep": { + "copyLink": "リンクをコピー", + "done": "完了", + "message": "相手のアカウントがこのメールアドレスを使用していることを確認してください。", + "title": "このリンクを相手に送信してください" + }, + "message": "共有可能な招待リンクを作成して送信してください", + "placeholder": "招待する人のメールアドレスを入力", + "title": "このワークスペースに人を招待" + }, + "leaveDialog": { + "leave": "退出", + "message": "ワークスペースのオーナーに連絡しない限り、再参加できません。", + "title": "このワークスペースを退出しますか?" + }, + "members": { + "actions": { + "copyLink": "招待リンクをコピー", + "removeMember": "メンバーを削除", + "revokeInvite": "招待を取り消す" + }, + "columns": { + "expiryDate": "有効期限", + "inviteDate": "招待日", + "joinDate": "参加日" + }, + "createNewWorkspace": "新しいワークスペースを作成してください。", + "membersCount": "{count}/50人のメンバー", + "noInvites": "保留中の招待はありません", + "noMembers": "メンバーがいません", + "pendingInvitesCount": "{count}件の招待保留中", + "personalWorkspaceMessage": "現在、個人用ワークスペースには他のメンバーを招待できません。メンバーを追加するには、", + "tabs": { + "active": "アクティブ", + "pendingCount": "保留中({count})" + } + }, + "menu": { + "deleteWorkspace": "ワークスペースを削除", + "deleteWorkspaceDisabledTooltip": "まずワークスペースの有効なサブスクリプションをキャンセルしてください", + "editWorkspace": "ワークスペースの詳細を編集", + "leaveWorkspace": "ワークスペースを退出" + }, + "removeMemberDialog": { + "error": "メンバーの削除に失敗しました", + "message": "このメンバーはワークスペースから削除されます。使用済みのクレジットは返金されません。", + "remove": "メンバーを削除", + "success": "メンバーを削除しました", + "title": "このメンバーを削除しますか?" + }, + "revokeInviteDialog": { + "message": "このメンバーは今後ワークスペースに参加できなくなります。招待リンクは無効になります。", + "revoke": "招待を取り消す", + "title": "この人の招待を取り消しますか?" + }, + "tabs": { + "dashboard": "ダッシュボード", + "membersCount": "メンバー({count})", + "planCredits": "プランとクレジット" + }, + "toast": { + "failedToCreateWorkspace": "ワークスペースの作成に失敗しました", + "failedToDeleteWorkspace": "ワークスペースの削除に失敗しました", + "failedToFetchWorkspaces": "ワークスペースの読み込みに失敗しました", + "failedToLeaveWorkspace": "ワークスペースの退出に失敗しました", + "failedToUpdateWorkspace": "ワークスペースの更新に失敗しました", + "workspaceCreated": { + "message": "プランに加入し、チームメイトを招待して、コラボレーションを始めましょう。", + "subscribe": "プランに加入", + "title": "ワークスペースを作成しました" + }, + "workspaceDeleted": { + "message": "ワークスペースは完全に削除されました。", + "title": "ワークスペースを削除しました" + }, + "workspaceLeft": { + "message": "ワークスペースから退出しました。", + "title": "ワークスペースから退出しました" + }, + "workspaceUpdated": { + "message": "ワークスペースの詳細が保存されました。", + "title": "ワークスペースが更新されました" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "新しいワークスペースを作成", + "maxWorkspacesReached": "所有できるワークスペースは10個までです。新しく作成するには1つ削除してください。", + "personal": "個人用", + "roleMember": "メンバー", + "roleOwner": "オーナー", + "subscribe": "サブスクライブ", + "switchWorkspace": "ワークスペースを切り替え" + }, "zoomControls": { "hideMinimap": "ミニマップを非表示", "label": "ズームコントロール", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 2471197e8..667d57258 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Briaの最新モデルを使って画像を編集します", + "display_name": "Bria画像編集", + "inputs": { + "control_after_generate": { + "name": "生成後コントロール" + }, + "guidance_scale": { + "name": "ガイダンススケール", + "tooltip": "値が高いほどプロンプトに忠実な画像になります。" + }, + "image": { + "name": "画像" + }, + "mask": { + "name": "マスク", + "tooltip": "省略した場合、編集は画像全体に適用されます。" + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "モデレーション", + "tooltip": "モデレーション設定" + }, + "moderation_prompt_content_moderation": { + "name": "プロンプト内容モデレーション" + }, + "moderation_visual_input_moderation": { + "name": "入力画像モデレーション" + }, + "moderation_visual_output_moderation": { + "name": "出力画像モデレーション" + }, + "negative_prompt": { + "name": "ネガティブプロンプト" + }, + "prompt": { + "name": "プロンプト", + "tooltip": "画像編集の指示" + }, + "seed": { + "name": "シード" + }, + "steps": { + "name": "ステップ数" + }, + "structured_prompt": { + "name": "構造化プロンプト", + "tooltip": "JSON形式の構造化編集プロンプトを含む文字列。より正確でプログラム的な制御が必要な場合は通常のプロンプトの代わりに使用してください。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "構造化プロンプト", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "プロンプトと最初・最後のフレームを使用して動画を生成します。", "display_name": "ByteDance 最初-最後フレームから動画生成", @@ -351,6 +413,10 @@ "name": "最初のフレーム", "tooltip": "動画に使用する最初のフレーム。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "last_frame": { "name": "最後のフレーム", "tooltip": "動画に使用する最後のフレーム。" @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "プロンプトに基づいてAPI経由でByteDanceモデルを使用して画像を編集", - "display_name": "ByteDance画像編集", - "inputs": { - "control_after_generate": { - "name": "生成後の制御" - }, - "guidance_scale": { - "name": "ガイダンススケール", - "tooltip": "値が高いほどプロンプトに忠実な画像になります" - }, - "image": { - "name": "画像", - "tooltip": "編集するベース画像" - }, - "model": { - "name": "モデル" - }, - "prompt": { - "name": "プロンプト", - "tooltip": "画像編集の指示" - }, - "seed": { - "name": "シード", - "tooltip": "生成に使用するシード値" - }, - "watermark": { - "name": "透かし", - "tooltip": "画像に「AI生成」の透かしを追加するかどうか" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "プロンプトに基づいてAPI経由でByteDanceモデルを使用して画像を生成", "display_name": "ByteDance画像", @@ -527,6 +556,10 @@ "name": "duration", "tooltip": "出力動画の長さ(秒単位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "image": { "name": "image", "tooltip": "動画の最初のフレームとして使用する画像。" @@ -634,6 +667,10 @@ "name": "長さ", "tooltip": "出力動画の長さ(秒単位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "このパラメータは、seedance-1-5-pro 以外のモデルでは無視されます。" + }, "model": { "name": "モデル" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "選択" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "インデックス", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "ControlNetモデルを読み込む(diff)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "3Dを読み込む", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "高さ" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "モデルファイル" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "幅" } @@ -6270,13 +6306,11 @@ "description": "出力フォルダから画像を読み込みます。更新ボタンをクリックすると、ノードは画像リストを更新し、自動的に最初の画像を選択します。これにより、簡単に反復処理が可能になります。", "display_name": "画像の読み込み(出力から)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "画像" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "アップロードするファイルを選択" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "LoRAをバイパスモードで適用します。通常のLoRAとは異なり、モデルの重みを変更せず、フォワードパス中にLoRAの計算を注入します。トレーニングシナリオで便利です。", + "display_name": "LoRAの読み込み(バイパス)(デバッグ用)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "LoRAを適用するCLIPモデル。" + }, + "lora_name": { + "name": "lora_name", + "tooltip": "LoRAの名前。" + }, + "model": { + "name": "model", + "tooltip": "LoRAを適用する拡散モデル。" + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "CLIPモデルをどの程度変更するか。この値は負にもできます。" + }, + "strength_model": { + "name": "strength_model", + "tooltip": "拡散モデルをどの程度変更するか。この値は負にもできます。" + } + }, + "outputs": { + "0": { + "tooltip": "変更された拡散モデル。" + }, + "1": { + "tooltip": "変更されたCLIPモデル。" + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "LoRAをバイパスモードで適用します。通常のLoRAとは異なり、モデルの重みを変更せず、フォワードパス中にLoRAの計算を注入します。トレーニングシナリオで便利です。", + "display_name": "LoRAの読み込み(バイパス、モデルのみ)(デバッグ用)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "変更された拡散モデル。" + } + } + }, "LoraLoaderModelOnly": { "description": "LoRAは拡散およびCLIPモデルを修正するために使用され、潜在のノイズ除去方法を変更します。複数のLoRAノードを連結できます。", "display_name": "LoRAローダーモデルのみ", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "画像のライティングを調整し、オプションで参照画像からの光の転送も可能です。", + "display_name": "Magnific 画像リライティング", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "高度なライティング制御のための詳細設定。" + }, + "change_background": { + "name": "change_background", + "tooltip": "プロンプトや参照画像に基づいて背景を変更します。" + }, + "image": { + "name": "image", + "tooltip": "リライティングする画像。" + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "生成の自由度を制限し、元画像により近づけます。" + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "光転送の適用強度。" + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "元画像の質感や細部を維持します。" + }, + "prompt": { + "name": "prompt", + "tooltip": "ライティングのための説明的なガイダンス。強調表記(1-1.4)に対応。" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "光を転送するためのオプションの参照画像。" + }, + "style": { + "name": "style", + "tooltip": "出力のスタイル設定。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "複数の処理モードによるポートレートの肌補正。", + "display_name": "Magnific 画像スキンエンハンサー", + "inputs": { + "image": { + "name": "image", + "tooltip": "補正するポートレート画像。" + }, + "mode": { + "name": "mode", + "tooltip": "処理モード:creative(芸術的補正)、faithful(元の外観を維持)、flexible(ターゲット最適化)。" + }, + "sharpen": { + "name": "sharpen", + "tooltip": "シャープネスの強度レベル。" + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "スマートグレインの強度レベル。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "参照画像からスタイルを抽出し、入力画像に転送します。", + "display_name": "Magnific画像スタイル転送", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "処理エンジンの選択。" + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "無効にすると、各生成でランダム性が加わり、より多様な結果が得られます。" + }, + "flavor": { + "name": "flavor", + "tooltip": "スタイル転送のフレーバー。" + }, + "image": { + "name": "image", + "tooltip": "スタイル転送を適用する画像。" + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "顔の強調のためにポートレートモードを有効にします。" + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "スタイルを抽出する参照画像。" + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "元画像の構造を維持します。" + }, + "style_strength": { + "name": "style_strength", + "tooltip": "スタイル強度のパーセンテージ。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "ネガティブなプロンプトとの差ではなく、ポジティブなプロンプトの「方向」によりスケールするようにガイダンスを修正します。", "display_name": "Mahiroはとても可愛いので、より良いガイダンス機能が必要です!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "プレビュー任意", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "ソース" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "さまざまなスケーリング方法で画像またはマスクをリサイズします。", "display_name": "画像/マスクのリサイズ", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "リサイズ方法を選択します:正確な寸法、スケール係数、他の画像に合わせるなど。" }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "切り抜き" + }, + "resize_type_height": { + "name": "高さ" + }, + "resize_type_width": { + "name": "幅" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "補間アルゴリズム。「area」は縮小に最適、「lanczos」は拡大に最適、「nearest-exact」はドット絵に最適です。" } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: 画像からモデルへ (Pro)", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "face_count": { + "name": "面数" + }, + "generate_type": { + "name": "生成タイプ" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "image": { + "name": "画像" + }, + "image_back": { + "name": "背面画像" + }, + "image_left": { + "name": "左画像" + }, + "image_right": { + "name": "右画像" + }, + "model": { + "name": "モデル", + "tooltip": "`3.1`モデルではLowPolyオプションは利用できません。" + }, + "seed": { + "name": "シード", + "tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。" + } + }, + "outputs": { + "0": { + "name": "モデルファイル", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: テキストからモデルへ (Pro)", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "face_count": { + "name": "面数" + }, + "generate_type": { + "name": "生成タイプ" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "model": { + "name": "モデル", + "tooltip": "`3.1`モデルではLowPolyオプションは利用できません。" + }, + "prompt": { + "name": "プロンプト", + "tooltip": "最大1024文字まで対応しています。" + }, + "seed": { + "name": "シード", + "tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。" + } + }, + "outputs": { + "0": { + "name": "モデルファイル", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "画像自動リサイズ" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "画像1" + }, + "image2": { + "name": "画像2" + }, + "image3": { + "name": "画像3" + }, + "image_encoder": { + "name": "画像エンコーダ" + }, + "prompt": { + "name": "プロンプト" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "テキストを小文字に変換", "inputs": { @@ -13643,6 +13970,10 @@ "name": "バケットモード", "tooltip": "解像度バケットモードを有効にします。有効時は、ResolutionBucketノードから事前にバケット化されたlatentを期待します。" }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "トレーニング用のバイパスモードを有効にします。有効にすると、アダプターは重みの変更ではなくフォワードフックで適用されます。重みを直接変更できない量子化モデルで便利です。" + }, "control_after_generate": { "name": "control after generate" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "オーディオエンコーダ出力1" + }, + "audio_scale": { + "name": "オーディオスケール" + }, + "clip_vision_output": { + "name": "clipビジョン出力" + }, + "height": { + "name": "高さ" + }, + "length": { + "name": "長さ" + }, + "mode": { + "name": "モード" + }, + "model": { + "name": "モデル" + }, + "model_patch": { + "name": "モデルパッチ" + }, + "motion_frame_count": { + "name": "モーションフレーム数", + "tooltip": "動きのコンテキストとして使用する前のフレーム数。" + }, + "negative": { + "name": "ネガティブ" + }, + "positive": { + "name": "ポジティブ" + }, + "previous_frames": { + "name": "前のフレーム" + }, + "start_image": { + "name": "開始画像" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "幅" + } + }, + "outputs": { + "0": { + "name": "モデル", + "tooltip": null + }, + "1": { + "name": "ポジティブ", + "tooltip": null + }, + "2": { + "name": "ネガティブ", + "tooltip": null + }, + "3": { + "name": "潜在", + "tooltip": null + }, + "4": { + "name": "トリム画像", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "低解像度やぼやけた映像の解像度を向上させ、鮮明さを復元する高速・高品質なビデオアップスケーラーです。", + "display_name": "FlashVSRビデオ高解像度化", + "inputs": { + "target_resolution": { + "name": "目標解像度" + }, + "video": { + "name": "ビデオ" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "画像の解像度と品質を向上させ、写真を4Kや8Kにアップスケールしてシャープで詳細な結果を得られます。", + "display_name": "WaveSpeed画像高解像度化", + "inputs": { + "image": { + "name": "画像" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "目標解像度" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "ウェブカメラキャプチャ", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "画像" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "幅" } diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 02559179b..d24450fd0 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "選択ツールボックスを表示" + "name": "選択ツールボックスを表示", + "tooltip": "ノードが選択されているときにフローティングツールバーを表示し、よく使う操作に素早くアクセスできます。" }, "Comfy_ConfirmClear": { "name": "ワークフローをクリアする際に確認を要求する" @@ -147,7 +148,8 @@ "Linear": "リニア", "Spline": "スプライン", "Straight": "ストレート" - } + }, + "tooltip": "キャンバス上のノード間の接続リンクの外観と表示を制御します。" }, "Comfy_Load3D_3DViewerEnable": { "name": "3Dビューアーを有効化(ベータ)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "画像プレビューの下に幅×高さを表示する" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "すべてのノードで常に高度なウィジェットを表示", + "tooltip": "有効にすると、すべてのノードで高度なウィジェットが個別に展開しなくても常に表示されます。" + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "ノードスロットにリンクを自動スナップ", "tooltip": "ノードの上にリンクをドラッグすると、リンクがノードの有効な入力スロットに自動的にスナップします" @@ -332,6 +338,10 @@ "name": "キュー履歴サイズ", "tooltip": "キュー履歴に表示されるタスクの最大数。" }, + "Comfy_Queue_QPOV2": { + "name": "アセットサイドパネルで統一ジョブキューを使用", + "tooltip": "フローティングジョブキューパネルを、アセットサイドパネルに埋め込まれた同等のジョブキューに置き換えます。無効にすると、フローティングパネルのレイアウトに戻ります。" + }, "Comfy_Sidebar_Location": { "name": "サイドバーの位置", "options": { @@ -466,6 +476,7 @@ "tooltip": "リルート中心点からのベジエ制御点のオフセット" }, "pysssss_SnapToGrid": { - "name": "常にグリッドにスナップ" + "name": "常にグリッドにスナップ", + "tooltip": "有効にすると、ノードを移動またはサイズ変更した際に自動的にグリッドに揃えます。" } } diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 60b2f9711..524b89cf2 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -24,6 +24,7 @@ "assets": "에셋", "baseModels": "베이스 모델", "browseAssets": "에셋 탐색", + "byType": "유형별", "checkpoints": "체크포인트", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "예시:", @@ -45,6 +46,10 @@ "failed": "다운로드 실패", "inProgress": "{assetName} 다운로드 중..." }, + "emptyImported": { + "canImport": "아직 가져온 모델이 없습니다. \"모델 가져오기\"를 클릭하여 직접 추가하세요.", + "restricted": "개인 모델은 Creator 등급 이상에서만 사용할 수 있습니다." + }, "errorFileTooLarge": "파일이 허용된 최대 크기 제한을 초과했습니다", "errorFormatNotAllowed": "SafeTensor 형식만 허용됩니다", "errorModelTypeNotSupported": "이 모델 유형은 지원되지 않습니다", @@ -61,6 +66,7 @@ "finish": "완료", "genericLinkPlaceholder": "여기에 링크를 붙여넣으세요", "importAnother": "다른 항목 가져오기", + "imported": "가져온 항목", "jobId": "작업 ID", "loadingModels": "{type} 불러오는 중...", "maxFileSize": "최대 파일 크기: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3D 모델" }, "modelAssociatedWithLink": "제공하신 링크와 연결된 모델:", + "modelInfo": { + "addBaseModel": "베이스 모델 추가...", + "addTag": "태그 추가...", + "additionalTags": "추가 태그", + "baseModelUnknown": "베이스 모델 알 수 없음", + "basicInfo": "기본 정보", + "compatibleBaseModels": "호환 가능한 베이스 모델", + "description": "설명", + "descriptionNotSet": "설정된 설명 없음", + "descriptionPlaceholder": "이 모델에 대한 설명을 추가하세요...", + "displayName": "표시 이름", + "editDisplayName": "표시 이름 편집", + "fileName": "파일 이름", + "modelDescription": "모델 설명", + "modelTagging": "모델 태깅", + "modelType": "모델 유형", + "noAdditionalTags": "추가 태그 없음", + "selectModelPrompt": "모델을 선택하여 정보를 확인하세요", + "selectModelType": "모델 유형 선택...", + "source": "소스", + "title": "모델 정보", + "triggerPhrases": "트리거 문구", + "viewOnSource": "{source}에서 보기" + }, "modelName": "모델 이름", "modelNamePlaceholder": "이 모델의 이름을 입력하세요", "modelTypeSelectorLabel": "모델 유형은 무엇인가요?", @@ -238,6 +268,12 @@ "title": "계정 생성" } }, + "boundingBox": { + "height": "높이", + "width": "너비", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "워크플로 내용 지우기", "deleteBlueprint": "블루프린트 삭제", @@ -678,6 +714,7 @@ "clearAll": "모두 지우기", "clearFilters": "필터 지우기", "close": "닫기", + "closeDialog": "대화 상자 닫기", "color": "색상", "comfy": "Comfy", "comfyOrgLogoAlt": "ComfyOrg 로고", @@ -694,6 +731,7 @@ "control_before_generate": "생성 전 제어", "copied": "복사됨", "copy": "복사", + "copyAll": "모두 복사", "copyJobId": "작업 ID 복사", "copyToClipboard": "클립보드에 복사", "copyURL": "URL 복사", @@ -756,6 +794,8 @@ "goToNode": "노드로 이동", "graphNavigation": "그래프 탐색", "halfSpeed": "0.5배속", + "hideLeftPanel": "왼쪽 패널 숨기기", + "hideRightPanel": "오른쪽 패널 숨기기", "icon": "아이콘", "imageFailedToLoad": "이미지를 로드하지 못했습니다.", "imagePreview": "이미지 미리보기 - 화살표 키를 사용하여 이미지 간 이동", @@ -797,6 +837,7 @@ "name": "이름", "newFolder": "새 폴더", "next": "다음", + "nightly": "NIGHTLY", "no": "아니오", "noAudioRecorded": "녹음된 오디오가 없습니다", "noItems": "항목 없음", @@ -811,6 +852,7 @@ "nodeSlotsError": "노드 슬롯 오류", "nodeWidgetsError": "노드 위젯 오류", "nodes": "노드", + "nodesCount": "{count}개 노드 | {count}개 노드 | {count}개 노드", "nodesRunning": "노드 실행 중", "none": "없음", "nothingToCopy": "복사할 항목 없음", @@ -885,7 +927,9 @@ "selectedFile": "선택된 파일", "setAsBackground": "배경으로 설정", "settings": "설정", + "showLeftPanel": "왼쪽 패널 표시", "showReport": "보고서 보기", + "showRightPanel": "오른쪽 패널 표시", "singleSelectDropdown": "단일 선택 드롭다운", "sort": "정렬", "source": "소스", @@ -908,6 +952,7 @@ "updating": "업데이트 중", "upload": "업로드", "usageHint": "사용 힌트", + "use": "사용", "user": "사용자", "versionMismatchWarning": "버전 호환성 경고", "versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.", @@ -915,11 +960,10 @@ "videoPreview": "비디오 미리보기 - 화살표 키를 사용하여 비디오 간 이동", "viewImageOfTotal": "이미지 {index}/{total} 보기", "viewVideoOfTotal": "비디오 {index}/{total} 보기", - "vitePreloadErrorMessage": "앱의 새 버전이 출시되었습니다. 새로고침하시겠습니까?\n그렇지 않으면 앱의 일부 기능이 예상대로 작동하지 않을 수 있습니다.\n새로고침하기 전에 진행 상황을 저장하고 거부하는 것도 가능합니다.", - "vitePreloadErrorTitle": "새 버전 사용 가능", "volume": "볼륨", "warning": "경고", - "workflow": "워크플로" + "workflow": "워크플로", + "you": "당신" }, "graphCanvasMenu": { "fitView": "보기 맞춤", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "비교할 이미지가 없습니다" }, + "imageCrop": { + "cropPreviewAlt": "자르기 미리보기", + "loading": "로딩 중...", + "noInputImage": "입력 이미지가 연결되지 않았습니다" + }, "importFailed": { "copyError": "복사 오류", "title": "가져오기 실패" @@ -1606,17 +1655,25 @@ "title": "이 워크플로우에 누락된 노드가 있습니다" } }, + "nightly": { + "badge": { + "label": "미리보기 버전", + "tooltip": "현재 ComfyUI의 나이트리 버전을 사용 중입니다. 이 기능들에 대한 의견을 피드백 버튼을 통해 공유해 주세요." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "3D 모델", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_테스트용", "advanced": "고급", "animation": "애니메이션", @@ -1841,6 +1900,7 @@ }, "groupSettings": "그룹 설정", "groups": "그룹", + "hideAdvancedInputsButton": "고급 입력 숨기기", "hideInput": "입력 숨기기", "info": "정보", "inputs": "입력", @@ -2073,6 +2133,7 @@ "NodeLibrary": "노드 라이브러리", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "알림 환경설정", + "Other": "기타", "PLY": "PLY", "PlanCredits": "플랜 및 크레딧", "Pointer": "포인터", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Vue 노드", "VueNodes": "Vue 노드", "Window": "창", - "Workflow": "워크플로" + "Workflow": "워크플로", + "Workspace": "워크스페이스" }, "shape": { "CARD": "카드", @@ -2118,12 +2180,14 @@ "viewControls": "보기 컨트롤" }, "sideToolbar": { + "activeJobStatus": "진행 중인 작업: {status}", "assets": "에셋", "backToAssets": "모든 에셋으로 돌아가기", "browseTemplates": "예제 템플릿 탐색", "downloads": "다운로드", "generatedAssetsHeader": "생성된 에셋", "helpCenter": "도움말 센터", + "importedAssetsHeader": "가져온 에셋", "labels": { "assets": "에셋", "console": "콘솔", @@ -2168,6 +2232,7 @@ "queue": "실행 대기열", "queueProgressOverlay": { "activeJobs": "{count}개의 활성 작업", + "activeJobsShort": "{count}개 활성", "activeJobsSuffix": "활성 작업", "cancelJobTooltip": "작업 취소", "clearHistory": "작업 대기열 기록 삭제", @@ -2256,9 +2321,15 @@ "beta": "베타", "billedMonthly": "매월 결제", "billedYearly": "{total} 연간 결제", + "billingComingSoon": { + "message": "팀 결제 기능이 곧 제공됩니다. 워크스페이스별 좌석당 요금제로 구독할 수 있습니다. 업데이트를 기대해 주세요.", + "title": "곧 출시 예정" + }, + "cancelSubscription": "구독 취소", "changeTo": "{plan}로 변경", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud 로고", + "contactOwnerToSubscribe": "워크스페이스 소유자에게 구독을 요청하세요", "contactUs": "문의하기", "creditsRemainingThisMonth": "이번 달 남은 크레딧", "creditsRemainingThisYear": "올해 남은 크레딧", @@ -2271,6 +2342,7 @@ "haveQuestions": "질문이 있거나 엔터프라이즈가 궁금하신가요?", "invoiceHistory": "청구서 기록", "learnMore": "더 알아보기", + "managePayment": "결제 관리", "managePlan": "플랜 관리", "manageSubscription": "구독 관리", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "Comfy Cloud 구독", "subscribeToRun": "구독", "subscribeToRunFull": "실행 구독", + "subscriptionRequiredMessage": "클라우드에서 워크플로우를 실행하려면 멤버가 구독해야 합니다", "tierNameYearly": "{name} 연간", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "자세히 보기", "viewMoreDetailsPlans": "플랜 및 가격에 대한 자세한 정보 보기", "viewUsageHistory": "사용 기록 보기", + "workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다", "yearly": "연간", "yearlyCreditsLabel": "연간 총 크레딧", "yearlyDiscount": "20% 할인", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "3D 모델을 로드하지 못함", "failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}", "failedToQueue": "대기열 추가 실패", + "failedToSaveDraft": "워크플로우 초안 저장에 실패했습니다", "failedToToggleCamera": "카메라 전환 실패", "failedToToggleGrid": "그리드 전환 실패", "failedToUpdateBackgroundColor": "배경색 업데이트 실패", @@ -2486,7 +2561,8 @@ "notSet": "설정되지 않음", "provider": "로그인 방법", "title": "사용자 설정", - "updatePassword": "비밀번호 업데이트" + "updatePassword": "비밀번호 업데이트", + "workspaceSettings": "워크스페이스 설정" }, "validation": { "descriptionRequired": "설명은 필수입니다", @@ -2577,6 +2653,9 @@ "saveWorkflow": "워크플로 저장" }, "workspace": { + "addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다", + "inviteAccepted": "초대 수락됨", + "inviteFailed": "초대 수락에 실패했습니다", "unsavedChanges": { "message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?", "title": "저장되지 않은 변경 사항" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "워크스페이스를 찾을 수 없습니다." } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "만들기", + "message": "워크스페이스는 멤버들이 크레딧을 공유할 수 있게 해줍니다. 생성 후 소유자가 됩니다.", + "nameLabel": "워크스페이스 이름*", + "namePlaceholder": "워크스페이스 이름 입력", + "title": "새 워크스페이스 만들기" + }, + "dashboard": { + "placeholder": "대시보드 워크스페이스 설정" + }, + "deleteDialog": { + "message": "사용하지 않은 크레딧이나 저장되지 않은 자산이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", + "messageWithName": "\"{name}\"을(를) 삭제하시겠습니까? 사용하지 않은 크레딧이나 저장되지 않은 자산이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", + "title": "이 워크스페이스를 삭제하시겠습니까?" + }, + "editWorkspaceDialog": { + "nameLabel": "워크스페이스 이름", + "save": "저장", + "title": "워크스페이스 정보 수정" + }, + "invite": "초대", + "inviteLimitReached": "최대 50명의 멤버에 도달했습니다", + "inviteMember": "멤버 초대", + "inviteMemberDialog": { + "createLink": "링크 생성", + "linkCopied": "복사됨", + "linkCopyFailed": "링크 복사 실패", + "linkStep": { + "copyLink": "링크 복사", + "done": "완료", + "message": "상대방 계정이 이 이메일을 사용하는지 확인하세요.", + "title": "이 링크를 상대방에게 보내세요" + }, + "message": "공유 가능한 초대 링크를 생성하여 상대방에게 보내세요", + "placeholder": "이메일을 입력하세요", + "title": "이 워크스페이스에 사람 초대" + }, + "leaveDialog": { + "leave": "나가기", + "message": "워크스페이스 소유자에게 연락하지 않으면 다시 참여할 수 없습니다.", + "title": "이 워크스페이스를 나가시겠습니까?" + }, + "members": { + "actions": { + "copyLink": "초대 링크 복사", + "removeMember": "멤버 제거", + "revokeInvite": "초대 취소" + }, + "columns": { + "expiryDate": "만료 날짜", + "inviteDate": "초대 날짜", + "joinDate": "가입 날짜" + }, + "createNewWorkspace": "새 워크스페이스를 만드세요.", + "membersCount": "{count}/50 멤버", + "noInvites": "대기 중인 초대 없음", + "noMembers": "멤버 없음", + "pendingInvitesCount": "{count}건의 초대 대기 중 | {count}건의 초대 대기 중", + "personalWorkspaceMessage": "현재 개인 워크스페이스에는 다른 멤버를 초대할 수 없습니다. 멤버를 추가하려면", + "tabs": { + "active": "활성", + "pendingCount": "대기 중 ({count})" + } + }, + "menu": { + "deleteWorkspace": "워크스페이스 삭제", + "deleteWorkspaceDisabledTooltip": "먼저 워크스페이스의 활성 구독을 취소하세요", + "editWorkspace": "워크스페이스 정보 수정", + "leaveWorkspace": "워크스페이스 나가기" + }, + "removeMemberDialog": { + "error": "멤버 제거에 실패했습니다", + "message": "이 멤버는 워크스페이스에서 제거됩니다. 사용한 크레딧은 환불되지 않습니다.", + "remove": "멤버 제거", + "success": "멤버가 제거되었습니다", + "title": "이 멤버를 제거하시겠습니까?" + }, + "revokeInviteDialog": { + "message": "이 멤버는 더 이상 워크스페이스에 참여할 수 없습니다. 초대 링크가 무효화됩니다.", + "revoke": "초대 취소", + "title": "이 사람의 초대를 취소하시겠습니까?" + }, + "tabs": { + "dashboard": "대시보드", + "membersCount": "멤버 ({count})", + "planCredits": "플랜 및 크레딧" + }, + "toast": { + "failedToCreateWorkspace": "워크스페이스 생성에 실패했습니다", + "failedToDeleteWorkspace": "워크스페이스 삭제에 실패했습니다", + "failedToFetchWorkspaces": "워크스페이스 불러오기에 실패했습니다", + "failedToLeaveWorkspace": "워크스페이스 나가기에 실패했습니다", + "failedToUpdateWorkspace": "워크스페이스 업데이트에 실패했습니다", + "workspaceCreated": { + "message": "요금제 구독, 팀원 초대, 협업을 시작하세요.", + "subscribe": "구독하기", + "title": "워크스페이스 생성됨" + }, + "workspaceDeleted": { + "message": "워크스페이스가 영구적으로 삭제되었습니다.", + "title": "워크스페이스 삭제됨" + }, + "workspaceLeft": { + "message": "워크스페이스에서 나갔습니다.", + "title": "워크스페이스에서 나감" + }, + "workspaceUpdated": { + "message": "워크스페이스 정보가 저장되었습니다.", + "title": "워크스페이스가 업데이트되었습니다" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "새 워크스페이스 만들기", + "maxWorkspacesReached": "최대 10개의 워크스페이스만 소유할 수 있습니다. 새로 만들려면 하나를 삭제하세요.", + "personal": "개인", + "roleMember": "멤버", + "roleOwner": "소유자", + "subscribe": "구독하기", + "switchWorkspace": "워크스페이스 전환" + }, "zoomControls": { "hideMinimap": "미니맵 숨기기", "label": "줌 컨트롤", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index e531b55dc..fe54dfee5 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Bria 최신 모델을 사용하여 이미지를 편집합니다", + "display_name": "Bria 이미지 편집", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "guidance_scale": { + "name": "가이던스 스케일", + "tooltip": "값이 높을수록 프롬프트를 더 엄격하게 따릅니다." + }, + "image": { + "name": "image" + }, + "mask": { + "name": "마스크", + "tooltip": "생략하면 전체 이미지에 편집이 적용됩니다." + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "모더레이션", + "tooltip": "모더레이션 설정" + }, + "moderation_prompt_content_moderation": { + "name": "프롬프트 콘텐츠 모더레이션" + }, + "moderation_visual_input_moderation": { + "name": "비주얼 입력 모더레이션" + }, + "moderation_visual_output_moderation": { + "name": "비주얼 출력 모더레이션" + }, + "negative_prompt": { + "name": "네거티브 프롬프트" + }, + "prompt": { + "name": "프롬프트", + "tooltip": "이미지 편집을 위한 지시문" + }, + "seed": { + "name": "시드" + }, + "steps": { + "name": "스텝" + }, + "structured_prompt": { + "name": "구조화된 프롬프트", + "tooltip": "JSON 형식의 구조화된 편집 프롬프트 문자열입니다. 더 정확하고 프로그래밍적으로 제어하려면 일반 프롬프트 대신 사용하세요." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "구조화된 프롬프트", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "프롬프트와 첫 번째 및 마지막 프레임을 사용하여 비디오를 생성합니다.", "display_name": "ByteDance 첫-마지막-프레임에서 비디오 생성", @@ -351,6 +413,10 @@ "name": "first_frame", "tooltip": "비디오에 사용될 첫 번째 프레임입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "last_frame": { "name": "last_frame", "tooltip": "비디오에 사용될 마지막 프레임입니다." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "프롬프트 기반으로 ByteDance 모델을 사용하여 이미지 편집", - "display_name": "ByteDance 이미지 편집", - "inputs": { - "control_after_generate": { - "name": "생성 후 제어" - }, - "guidance_scale": { - "name": "가이던스 스케일", - "tooltip": "값이 높을수록 이미지가 프롬프트를 더 밀접하게 따름" - }, - "image": { - "name": "이미지", - "tooltip": "편집할 기본 이미지" - }, - "model": { - "name": "모델" - }, - "prompt": { - "name": "프롬프트", - "tooltip": "이미지 편집 지시사항" - }, - "seed": { - "name": "시드", - "tooltip": "생성에 사용할 시드 값" - }, - "watermark": { - "name": "워터마크", - "tooltip": "이미지에 \"AI 생성\" 워터마크를 추가할지 여부" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "프롬프트 기반으로 ByteDance 모델을 사용하여 이미지 생성", "display_name": "ByteDance 이미지", @@ -527,6 +556,10 @@ "name": "지속 시간", "tooltip": "출력 비디오의 지속 시간(초)입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "image": { "name": "이미지", "tooltip": "비디오에 사용할 첫 번째 프레임입니다." @@ -634,6 +667,10 @@ "name": "duration", "tooltip": "출력 비디오의 지속 시간(초)입니다." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "이 매개변수는 seedance-1-5-pro 모델을 제외한 모든 모델에서 무시됩니다." + }, "model": { "name": "model" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "선택" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "INDEX", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "컨트롤넷 모델 로드 (차이)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "3D 불러오기", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "높이" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "모델 파일" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "너비" } @@ -6270,13 +6306,11 @@ "description": "입력(input) 폴더 대신 출력(output) 폴더에서 이미지를 로드합니다. 새로 고침 버튼을 클릭하면 노드는 이미지 목록을 업데이트하고 자동으로 첫 번째 이미지를 선택하여 쉬운 반복을 가능하게 합니다.", "display_name": "이미지 로드 (출력에서)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "이미지" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "업로드할 파일 선택" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "LoRA를 바이패스 모드로 적용합니다. 일반 LoRA와 달리 모델 가중치를 수정하지 않고, 순전파 과정에서 LoRA 연산을 주입합니다. 학습 시나리오에 유용합니다.", + "display_name": "LoRA 불러오기 (바이패스) (디버깅용)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "LoRA가 적용될 CLIP 모델입니다." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "LoRA의 이름입니다." + }, + "model": { + "name": "model", + "tooltip": "LoRA가 적용될 디퓨전 모델입니다." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "CLIP 모델을 얼마나 강하게 수정할지 설정합니다. 이 값은 음수도 가능합니다." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "디퓨전 모델을 얼마나 강하게 수정할지 설정합니다. 이 값은 음수도 가능합니다." + } + }, + "outputs": { + "0": { + "tooltip": "수정된 디퓨전 모델입니다." + }, + "1": { + "tooltip": "수정된 CLIP 모델입니다." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "LoRA를 바이패스 모드로 적용합니다. 일반 LoRA와 달리 모델 가중치를 수정하지 않고, 순전파 과정에서 LoRA 연산을 주입합니다. 학습 시나리오에 유용합니다.", + "display_name": "LoRA 불러오기 (바이패스, 모델만) (디버깅용)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "수정된 디퓨전 모델입니다." + } + } + }, "LoraLoaderModelOnly": { "description": "LoRA는 확산 모델과 CLIP 모델을 부분적으로 변경해서, 잠재 데이터의 노이즈 제거 방향을 바꿉니다. 여러 LoRA 노드를 연결할 수 있습니다. (이 노드는 CLIP 모델은 로드하지 않습니다.)", "display_name": "LoRA 로드 (모델 전용)", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "이미지의 조명을 조정하고, 선택적으로 참조 기반의 조명 전송을 적용합니다.", + "display_name": "Magnific 이미지 리라이트", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "고급 조명 제어를 위한 세부 조정 옵션입니다." + }, + "change_background": { + "name": "change_background", + "tooltip": "프롬프트/참조에 따라 배경을 수정합니다." + }, + "image": { + "name": "image", + "tooltip": "리라이트할 이미지입니다." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "생성의 자유도를 제한하여 원본과 더 유사하게 만듭니다." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "조명 전송 적용 강도입니다." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "원본의 질감과 세부 정보를 유지합니다." + }, + "prompt": { + "name": "prompt", + "tooltip": "조명에 대한 설명 가이드입니다. 강조 표기법(1-1.4)을 지원합니다." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "조명을 전송할 선택적 참조 이미지입니다." + }, + "style": { + "name": "style", + "tooltip": "스타일 출력 선호도입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "여러 처리 모드를 지원하는 인물 사진 피부 보정 기능입니다.", + "display_name": "Magnific 이미지 피부 보정", + "inputs": { + "image": { + "name": "image", + "tooltip": "보정할 인물 사진입니다." + }, + "mode": { + "name": "mode", + "tooltip": "처리 모드: 창의적(artistic) 보정, 원본 유지(faithful), 타겟 최적화(flexible) 중 선택." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "선명도 강도 수준입니다." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "스마트 그레인 강도 수준입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "참조 이미지의 스타일을 입력 이미지에 전송합니다.", + "display_name": "Magnific 이미지 스타일 전송", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "처리 엔진 선택입니다." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "비활성화 시, 각 생성마다 일정 수준의 무작위성이 도입되어 더 다양한 결과를 얻을 수 있습니다." + }, + "flavor": { + "name": "flavor", + "tooltip": "스타일 전송의 flavor입니다." + }, + "image": { + "name": "image", + "tooltip": "스타일 전송을 적용할 이미지입니다." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "얼굴 개선을 위한 인물 모드를 활성화합니다." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "스타일을 추출할 참조 이미지입니다." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "원본 이미지의 구조를 유지합니다." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "스타일 강도 비율입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "노이즈 제거의 '방향'을 긍정 조건과 부정 조건 차에 의한 것 보다는 긍정 조건 방향으로 더 크게 작동하도록 가이더 동작을 변경합니다.", "display_name": "마히로", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "미리보기 아무거나", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "소스" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "여러 가지 스케일링 방법을 사용하여 이미지 또는 mask를 리사이즈합니다.", "display_name": "이미지/마스크 크기 조정", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "리사이즈 방법을 선택하세요: 정확한 크기, 배율, 다른 이미지와 일치 등." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "crop" + }, + "resize_type_height": { + "name": "height" + }, + "resize_type_width": { + "name": "width" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "보간 알고리즘입니다. 'area'는 축소에, 'lanczos'는 확대에, 'nearest-exact'는 픽셀 아트에 적합합니다." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: 이미지 → 모델 (Pro)", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "face_count": { + "name": "면 개수" + }, + "generate_type": { + "name": "생성 유형" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "image": { + "name": "이미지" + }, + "image_back": { + "name": "뒷면 이미지" + }, + "image_left": { + "name": "왼쪽 이미지" + }, + "image_right": { + "name": "오른쪽 이미지" + }, + "model": { + "name": "모델", + "tooltip": "`3.1` 모델에서는 LowPoly 옵션을 사용할 수 없습니다." + }, + "seed": { + "name": "시드", + "tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다." + } + }, + "outputs": { + "0": { + "name": "모델 파일", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: 텍스트 → 모델 (Pro)", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "face_count": { + "name": "면 개수" + }, + "generate_type": { + "name": "생성 유형" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "model": { + "name": "모델", + "tooltip": "`3.1` 모델에서는 LowPoly 옵션을 사용할 수 없습니다." + }, + "prompt": { + "name": "프롬프트", + "tooltip": "최대 1024자까지 지원합니다." + }, + "seed": { + "name": "시드", + "tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다." + } + }, + "outputs": { + "0": { + "name": "모델 파일", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "이미지 자동 크기 조정" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "이미지1" + }, + "image2": { + "name": "이미지2" + }, + "image3": { + "name": "이미지3" + }, + "image_encoder": { + "name": "이미지 인코더" + }, + "prompt": { + "name": "프롬프트" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "텍스트 소문자 변환", "inputs": { @@ -13643,6 +13970,10 @@ "name": "bucket_mode", "tooltip": "해상도 버킷 모드를 활성화합니다. 활성화 시, ResolutionBucket 노드에서 미리 버킷 처리된 latent를 기대합니다." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "학습을 위한 바이패스 모드를 활성화합니다. 활성화 시, 어댑터가 가중치 수정 대신 forward hook을 통해 적용됩니다. 가중치를 직접 수정할 수 없는 양자화된 모델에 유용합니다." + }, "control_after_generate": { "name": "생성 후 제어" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "audio_encoder_output_1" + }, + "audio_scale": { + "name": "audio_scale" + }, + "clip_vision_output": { + "name": "clip_vision_output" + }, + "height": { + "name": "height" + }, + "length": { + "name": "length" + }, + "mode": { + "name": "mode" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "motion_frame_count": { + "name": "motion_frame_count", + "tooltip": "이전 프레임 수를 모션 컨텍스트로 사용합니다." + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "previous_frames": { + "name": "previous_frames" + }, + "start_image": { + "name": "start_image" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "width" + } + }, + "outputs": { + "0": { + "name": "model", + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "trim_image", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "저해상도 또는 흐릿한 영상의 해상도를 높이고 선명도를 복원하는 빠르고 고품질의 비디오 업스케일러입니다.", + "display_name": "FlashVSR 비디오 업스케일", + "inputs": { + "target_resolution": { + "name": "목표 해상도" + }, + "video": { + "name": "비디오" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "이미지 해상도와 품질을 높여 사진을 4K 또는 8K로 업스케일하여 선명하고 디테일한 결과를 제공합니다.", + "display_name": "WaveSpeed 이미지 업스케일", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "목표 해상도" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "웹캠 캡처", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "이미지" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "너비" } diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 415d742b8..aad81ea74 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "선택 도구 상자 표시" + "name": "선택 도구 상자 표시", + "tooltip": "노드가 선택되었을 때 공통 작업에 빠르게 접근할 수 있는 플로팅 툴바를 표시합니다." }, "Comfy_ConfirmClear": { "name": "워크플로 비우기 시 확인 요구" @@ -147,7 +148,8 @@ "Linear": "선형", "Spline": "스플라인", "Straight": "직선" - } + }, + "tooltip": "캔버스에서 노드 간 연결 링크의 모양과 표시 여부를 제어합니다." }, "Comfy_Load3D_3DViewerEnable": { "name": "3D 뷰어 활성화 (베타)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "이미지 미리보기 아래에 너비 × 높이 표시" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "모든 노드에서 고급 위젯 항상 표시", + "tooltip": "이 옵션을 활성화하면, 모든 노드에서 고급 위젯이 개별적으로 확장하지 않아도 항상 표시됩니다." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "링크를 노드 슬롯에 자동 스냅", "tooltip": "링크를 노드 위로 드래그할 때 링크가 노드의 유효한 입력 슬롯에 자동으로 스냅됩니다." @@ -332,6 +338,10 @@ "name": "실행 큐 기록 갯수", "tooltip": "실행 큐 기록에 표시되는 최대 작업 수입니다." }, + "Comfy_Queue_QPOV2": { + "name": "에셋 사이드 패널에서 통합 작업 큐 사용", + "tooltip": "떠다니는 작업 큐 패널을 에셋 사이드 패널에 내장된 동등한 작업 큐로 대체합니다. 이 옵션을 비활성화하면 기존의 떠다니는 패널 레이아웃으로 돌아갈 수 있습니다." + }, "Comfy_Sidebar_Location": { "name": "사이드바 위치", "options": { @@ -466,6 +476,7 @@ "tooltip": "경유점 중심에서 베지어 제어점까지의 오프셋" }, "pysssss_SnapToGrid": { - "name": "항상 그리드에 스냅" + "name": "항상 그리드에 스냅", + "tooltip": "활성화하면 노드를 이동하거나 크기를 조정할 때 자동으로 그리드에 정렬됩니다." } } diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 160f525f5..62059f04e 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -24,6 +24,7 @@ "assets": "Ativos", "baseModels": "Modelos base", "browseAssets": "Explorar Ativos", + "byType": "Por tipo", "checkpoints": "Checkpoints", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "Exemplo:", @@ -45,6 +46,10 @@ "failed": "Falha no download", "inProgress": "Baixando {assetName}..." }, + "emptyImported": { + "canImport": "Nenhum modelo importado ainda. Clique em \"Importar Modelo\" para adicionar o seu.", + "restricted": "Modelos pessoais estão disponíveis apenas no nível Creator ou superior." + }, "errorFileTooLarge": "O arquivo excede o limite máximo de tamanho permitido", "errorFormatNotAllowed": "Apenas o formato SafeTensor é permitido", "errorModelTypeNotSupported": "Este tipo de modelo não é suportado", @@ -61,6 +66,7 @@ "finish": "Concluir", "genericLinkPlaceholder": "Cole o link aqui", "importAnother": "Importar outro", + "imported": "Importado", "jobId": "ID do trabalho", "loadingModels": "Carregando {type}...", "maxFileSize": "Tamanho máximo do arquivo: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "Modelo 3D" }, "modelAssociatedWithLink": "O modelo associado ao link fornecido:", + "modelInfo": { + "addBaseModel": "Adicionar modelo base...", + "addTag": "Adicionar tag...", + "additionalTags": "Tags Adicionais", + "baseModelUnknown": "Modelo base desconhecido", + "basicInfo": "Informações Básicas", + "compatibleBaseModels": "Modelos Base Compatíveis", + "description": "Descrição", + "descriptionNotSet": "Nenhuma descrição definida", + "descriptionPlaceholder": "Adicione uma descrição para este modelo...", + "displayName": "Nome de Exibição", + "editDisplayName": "Editar nome de exibição", + "fileName": "Nome do Arquivo", + "modelDescription": "Descrição do Modelo", + "modelTagging": "Tagueamento do Modelo", + "modelType": "Tipo de Modelo", + "noAdditionalTags": "Sem tags adicionais", + "selectModelPrompt": "Selecione um modelo para ver suas informações", + "selectModelType": "Selecione o tipo de modelo...", + "source": "Fonte", + "title": "Informações do Modelo", + "triggerPhrases": "Frases de Ativação", + "viewOnSource": "Ver em {source}" + }, "modelName": "Nome do modelo", "modelNamePlaceholder": "Digite um nome para este modelo", "modelTypeSelectorLabel": "Qual o tipo deste modelo?", @@ -238,6 +268,12 @@ "title": "Criar uma conta" } }, + "boundingBox": { + "height": "Altura", + "width": "Largura", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Limpar Fluxo de Trabalho", "deleteBlueprint": "Excluir Blueprint", @@ -678,6 +714,7 @@ "clearAll": "Limpar tudo", "clearFilters": "Limpar filtros", "close": "Fechar", + "closeDialog": "Fechar diálogo", "color": "Cor", "comfy": "Comfy", "comfyOrgLogoAlt": "Logo do ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "controle antes de gerar", "copied": "Copiado", "copy": "Copiar", + "copyAll": "Copiar tudo", "copyJobId": "Copiar ID da tarefa", "copyToClipboard": "Copiar para a área de transferência", "copyURL": "Copiar URL", @@ -756,6 +794,8 @@ "goToNode": "Ir para o nó", "graphNavigation": "Navegação no grafo", "halfSpeed": "0,5x", + "hideLeftPanel": "Ocultar painel esquerdo", + "hideRightPanel": "Ocultar painel direito", "icon": "Ícone", "imageFailedToLoad": "Falha ao carregar imagem", "imagePreview": "Pré-visualização da imagem - Use as setas para navegar entre as imagens", @@ -797,6 +837,7 @@ "name": "Nome", "newFolder": "Nova pasta", "next": "Próximo", + "nightly": "NIGHTLY", "no": "Não", "noAudioRecorded": "Nenhum áudio gravado", "noItems": "Nenhum item", @@ -811,6 +852,7 @@ "nodeSlotsError": "Erro nos slots do nó", "nodeWidgetsError": "Erro nos widgets do nó", "nodes": "Nós", + "nodesCount": "{count} nós | {count} nó | {count} nós", "nodesRunning": "nós em execução", "none": "Nenhum", "nothingToCopy": "Nada para copiar", @@ -885,7 +927,9 @@ "selectedFile": "Arquivo selecionado", "setAsBackground": "Definir como plano de fundo", "settings": "Configurações", + "showLeftPanel": "Mostrar painel esquerdo", "showReport": "Mostrar relatório", + "showRightPanel": "Mostrar painel direito", "singleSelectDropdown": "Menu suspenso de seleção única", "sort": "Ordenar", "source": "Fonte", @@ -908,6 +952,7 @@ "updating": "Atualizando {id}", "upload": "Enviar", "usageHint": "Dica de uso", + "use": "Usar", "user": "Usuário", "versionMismatchWarning": "Aviso de compatibilidade de versão", "versionMismatchWarningMessage": "{warning}: {detail} Visite https://docs.comfy.org/installation/update_comfyui#common-update-issues para instruções de atualização.", @@ -915,11 +960,10 @@ "videoPreview": "Pré-visualização do vídeo - Use as setas para navegar entre os vídeos", "viewImageOfTotal": "Visualizar imagem {index} de {total}", "viewVideoOfTotal": "Visualizar vídeo {index} de {total}", - "vitePreloadErrorMessage": "Uma nova versão do aplicativo foi lançada. Deseja recarregar?\nSe não, algumas partes do aplicativo podem não funcionar como esperado.\nSinta-se à vontade para recusar e salvar seu progresso antes de recarregar.", - "vitePreloadErrorTitle": "Nova versão disponível", "volume": "Volume", "warning": "Aviso", - "workflow": "Fluxo de trabalho" + "workflow": "Fluxo de trabalho", + "you": "Você" }, "graphCanvasMenu": { "fitView": "Ajustar à Tela", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "Nenhuma imagem para comparar" }, + "imageCrop": { + "cropPreviewAlt": "Pré-visualização do recorte", + "loading": "Carregando...", + "noInputImage": "Nenhuma imagem de entrada conectada" + }, "importFailed": { "copyError": "Erro ao Copiar", "title": "Falha na Importação" @@ -1606,17 +1655,25 @@ "title": "Este fluxo de trabalho possui nós ausentes" } }, + "nightly": { + "badge": { + "label": "Versão de Prévia", + "tooltip": "Você está usando uma versão nightly do ComfyUI. Por favor, use o botão de feedback para compartilhar suas opiniões sobre esses recursos." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "modelos_3d", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_for_testing", "advanced": "avançado", "animation": "animação", @@ -1841,6 +1900,7 @@ }, "groupSettings": "Configurações de Grupo", "groups": "Grupos", + "hideAdvancedInputsButton": "Ocultar entradas avançadas", "hideInput": "Ocultar entrada", "info": "Informações", "inputs": "ENTRADAS", @@ -2073,6 +2133,7 @@ "NodeLibrary": "Biblioteca de Nós", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "Preferências de Notificação", + "Other": "Outros", "PLY": "PLY", "PlanCredits": "Plano & Créditos", "Pointer": "Ponteiro", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Nodes 2.0", "VueNodes": "Nodes 2.0", "Window": "Janela", - "Workflow": "Fluxo de Trabalho" + "Workflow": "Fluxo de Trabalho", + "Workspace": "Espaço de trabalho" }, "shape": { "CARD": "Cartão", @@ -2118,12 +2180,14 @@ "viewControls": "Controles de Visualização" }, "sideToolbar": { + "activeJobStatus": "Tarefa ativa: {status}", "assets": "Ativos", "backToAssets": "Voltar para todos os ativos", "browseTemplates": "Explorar modelos de exemplo", "downloads": "Downloads", "generatedAssetsHeader": "Ativos gerados", "helpCenter": "Central de Ajuda", + "importedAssetsHeader": "Ativos importados", "labels": { "assets": "Ativos", "console": "Console", @@ -2179,6 +2243,7 @@ "queue": "Fila", "queueProgressOverlay": { "activeJobs": "{count} trabalho ativo | {count} trabalhos ativos", + "activeJobsShort": "{count} ativo(s) | {count} ativo(s)", "activeJobsSuffix": "trabalhos ativos", "cancelJobTooltip": "Cancelar trabalho", "clearHistory": "Limpar histórico da fila de trabalhos", @@ -2267,9 +2332,15 @@ "beta": "BETA", "billedMonthly": "Cobrado mensalmente", "billedYearly": "{total} Cobrado anualmente", + "billingComingSoon": { + "message": "A cobrança para equipes estará disponível em breve. Você poderá assinar um plano para seu workspace com preço por usuário. Fique atento para novidades.", + "title": "Em breve" + }, + "cancelSubscription": "Cancelar assinatura", "changeTo": "Mudar para {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo do Comfy Cloud", + "contactOwnerToSubscribe": "Entre em contato com o proprietário do espaço de trabalho para assinar", "contactUs": "Fale conosco", "creditsRemainingThisMonth": "Créditos restantes neste mês", "creditsRemainingThisYear": "Créditos restantes neste ano", @@ -2282,6 +2353,7 @@ "haveQuestions": "Tem dúvidas ou interesse em soluções empresariais?", "invoiceHistory": "Histórico de faturas", "learnMore": "Saiba mais", + "managePayment": "Gerenciar pagamento", "managePlan": "Gerenciar plano", "manageSubscription": "Gerenciar assinatura", "maxDuration": { @@ -2317,6 +2389,7 @@ "subscribeToComfyCloud": "Assine o Comfy Cloud", "subscribeToRun": "Assinar", "subscribeToRunFull": "Assine para Executar", + "subscriptionRequiredMessage": "Uma assinatura é necessária para que os membros executem fluxos de trabalho na Nuvem", "tierNameYearly": "{name} Anual", "tiers": { "creator": { @@ -2348,6 +2421,7 @@ "viewMoreDetails": "Ver mais detalhes", "viewMoreDetailsPlans": "Veja mais detalhes sobre planos e preços", "viewUsageHistory": "Ver histórico de uso", + "workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura", "yearly": "Anual", "yearlyCreditsLabel": "Total de créditos anuais", "yearlyDiscount": "20% DE DESCONTO", @@ -2449,6 +2523,7 @@ "failedToLoadModel": "Falha ao carregar modelo 3D", "failedToPurchaseCredits": "Falha ao comprar créditos: {error}", "failedToQueue": "Falha ao enfileirar", + "failedToSaveDraft": "Falha ao salvar o rascunho do fluxo de trabalho", "failedToToggleCamera": "Falha ao alternar câmera", "failedToToggleGrid": "Falha ao alternar grade", "failedToUpdateBackgroundColor": "Falha ao atualizar cor de fundo", @@ -2497,7 +2572,8 @@ "notSet": "Não definido", "provider": "Provedor de login", "title": "Configurações da Minha Conta", - "updatePassword": "Atualizar senha" + "updatePassword": "Atualizar senha", + "workspaceSettings": "Configurações do espaço de trabalho" }, "validation": { "descriptionRequired": "Descrição é obrigatória", @@ -2588,6 +2664,9 @@ "saveWorkflow": "Salvar fluxo de trabalho" }, "workspace": { + "addedToWorkspace": "Você foi adicionado ao {workspaceName}", + "inviteAccepted": "Convite aceito", + "inviteFailed": "Falha ao aceitar convite", "unsavedChanges": { "message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?", "title": "Alterações não salvas" @@ -2602,6 +2681,128 @@ "workspaceNotFound": "Espaço de trabalho não encontrado" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "Criar", + "message": "Espaços de trabalho permitem que membros compartilhem um único saldo de créditos. Você se tornará o proprietário após criar este.", + "nameLabel": "Nome do espaço de trabalho*", + "namePlaceholder": "Digite o nome do espaço de trabalho", + "title": "Criar um novo espaço de trabalho" + }, + "dashboard": { + "placeholder": "Configurações do workspace do painel" + }, + "deleteDialog": { + "message": "Quaisquer créditos não utilizados ou ativos não salvos serão perdidos. Esta ação não pode ser desfeita.", + "messageWithName": "Excluir \"{name}\"? Quaisquer créditos não utilizados ou ativos não salvos serão perdidos. Esta ação não pode ser desfeita.", + "title": "Excluir este espaço de trabalho?" + }, + "editWorkspaceDialog": { + "nameLabel": "Nome do espaço de trabalho", + "save": "Salvar", + "title": "Editar detalhes do espaço de trabalho" + }, + "invite": "Convidar", + "inviteLimitReached": "Você atingiu o limite máximo de 50 membros", + "inviteMember": "Convidar membro", + "inviteMemberDialog": { + "createLink": "Criar link", + "linkCopied": "Copiado", + "linkCopyFailed": "Falha ao copiar link", + "linkStep": { + "copyLink": "Copiar link", + "done": "Concluído", + "message": "Certifique-se de que a conta dela usa este e-mail.", + "title": "Envie este link para a pessoa" + }, + "message": "Crie um link de convite compartilhável para enviar a alguém", + "placeholder": "Digite o e-mail da pessoa", + "title": "Convidar uma pessoa para este workspace" + }, + "leaveDialog": { + "leave": "Sair", + "message": "Você não poderá entrar novamente a menos que entre em contato com o proprietário do espaço de trabalho.", + "title": "Sair deste espaço de trabalho?" + }, + "members": { + "actions": { + "copyLink": "Copiar link do convite", + "removeMember": "Remover membro", + "revokeInvite": "Revogar convite" + }, + "columns": { + "expiryDate": "Data de expiração", + "inviteDate": "Data do convite", + "joinDate": "Data de entrada" + }, + "createNewWorkspace": "crie um novo.", + "membersCount": "{count}/50 Membros", + "noInvites": "Nenhum convite pendente", + "noMembers": "Nenhum membro", + "pendingInvitesCount": "{count} convite pendente | {count} convites pendentes", + "personalWorkspaceMessage": "No momento, você não pode convidar outros membros para seu workspace pessoal. Para adicionar membros a um workspace,", + "tabs": { + "active": "Ativo", + "pendingCount": "Pendente ({count})" + } + }, + "menu": { + "deleteWorkspace": "Excluir espaço de trabalho", + "deleteWorkspaceDisabledTooltip": "Cancele a assinatura ativa do seu espaço de trabalho primeiro", + "editWorkspace": "Editar detalhes do espaço de trabalho", + "leaveWorkspace": "Sair do espaço de trabalho" + }, + "removeMemberDialog": { + "error": "Falha ao remover membro", + "message": "Este membro será removido do seu workspace. Os créditos utilizados por ele não serão reembolsados.", + "remove": "Remover membro", + "success": "Membro removido", + "title": "Remover este membro?" + }, + "revokeInviteDialog": { + "message": "Este membro não poderá mais entrar no seu workspace. O link de convite será invalidado.", + "revoke": "Desfazer convite", + "title": "Desfazer convite para esta pessoa?" + }, + "tabs": { + "dashboard": "Painel", + "membersCount": "Membros ({count})", + "planCredits": "Plano e Créditos" + }, + "toast": { + "failedToCreateWorkspace": "Falha ao criar o espaço de trabalho", + "failedToDeleteWorkspace": "Falha ao excluir o espaço de trabalho", + "failedToFetchWorkspaces": "Falha ao carregar workspaces", + "failedToLeaveWorkspace": "Falha ao sair do espaço de trabalho", + "failedToUpdateWorkspace": "Falha ao atualizar o espaço de trabalho", + "workspaceCreated": { + "message": "Assine um plano, convide colegas e comece a colaborar.", + "subscribe": "Assinar", + "title": "Workspace criado" + }, + "workspaceDeleted": { + "message": "O workspace foi excluído permanentemente.", + "title": "Workspace excluído" + }, + "workspaceLeft": { + "message": "Você saiu do workspace.", + "title": "Saiu do workspace" + }, + "workspaceUpdated": { + "message": "Os detalhes do espaço de trabalho foram salvos.", + "title": "Espaço de trabalho atualizado" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "Criar novo espaço de trabalho", + "maxWorkspacesReached": "Você só pode possuir 10 espaços de trabalho. Exclua um para criar um novo.", + "personal": "Pessoal", + "roleMember": "Membro", + "roleOwner": "Proprietário", + "subscribe": "Assinar", + "switchWorkspace": "Trocar espaço de trabalho" + }, "zoomControls": { "hideMinimap": "Ocultar Minimapa", "label": "Controles de Zoom", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 410528631..ddb24c5c2 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Edite imagens usando o modelo mais recente da Bria", + "display_name": "Bria Image Edit", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "guidance_scale": { + "name": "escala_de_guia", + "tooltip": "Um valor mais alto faz com que a imagem siga o prompt mais de perto." + }, + "image": { + "name": "imagem" + }, + "mask": { + "name": "máscara", + "tooltip": "Se omitido, a edição será aplicada à imagem inteira." + }, + "model": { + "name": "modelo" + }, + "moderation": { + "name": "moderação", + "tooltip": "Configurações de moderação" + }, + "moderation_prompt_content_moderation": { + "name": "moderação_do_conteúdo_do_prompt" + }, + "moderation_visual_input_moderation": { + "name": "moderação_da_entrada_visual" + }, + "moderation_visual_output_moderation": { + "name": "moderação_da_saida_visual" + }, + "negative_prompt": { + "name": "prompt_negativo" + }, + "prompt": { + "name": "prompt", + "tooltip": "Instrução para editar a imagem" + }, + "seed": { + "name": "semente" + }, + "steps": { + "name": "passos" + }, + "structured_prompt": { + "name": "prompt_estruturado", + "tooltip": "Uma string contendo o prompt de edição estruturado em formato JSON. Use isso em vez do prompt comum para controle preciso e programático." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "prompt_estruturado", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Gere vídeo usando um prompt e os quadros inicial e final.", "display_name": "ByteDance Primeiro-Último-Frame para Vídeo", @@ -351,6 +413,10 @@ "name": "first_frame", "tooltip": "Primeiro quadro a ser usado para o vídeo." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "last_frame": { "name": "last_frame", "tooltip": "Último quadro a ser usado para o vídeo." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "Edite imagens usando modelos ByteDance via API com base em um prompt", - "display_name": "ByteDance Edição de Imagem", - "inputs": { - "control_after_generate": { - "name": "control after generate" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "Um valor mais alto faz a imagem seguir o prompt mais de perto" - }, - "image": { - "name": "image", - "tooltip": "A imagem base para editar" - }, - "model": { - "name": "model" - }, - "prompt": { - "name": "prompt", - "tooltip": "Instrução para editar a imagem" - }, - "seed": { - "name": "seed", - "tooltip": "Seed a ser usada para a geração" - }, - "watermark": { - "name": "watermark", - "tooltip": "Se deve adicionar uma marca d'água \"Gerado por IA\" à imagem" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "Gere imagens usando modelos ByteDance via API com base em um prompt", "display_name": "ByteDance Imagem", @@ -527,6 +556,10 @@ "name": "duração", "tooltip": "A duração do vídeo de saída em segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "image": { "name": "imagem", "tooltip": "Primeiro quadro a ser usado para o vídeo." @@ -634,6 +667,10 @@ "name": "duration", "tooltip": "A duração do vídeo de saída em segundos." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Este parâmetro é ignorado para qualquer modelo, exceto seedance-1-5-pro." + }, "model": { "name": "model" }, @@ -2021,14 +2058,16 @@ "choice": { "name": "escolha" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "ÍNDICE", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "Carregar Modelo ControlNet (diff)", @@ -6176,8 +6215,7 @@ "Load3D": { "display_name": "Carregar 3D & Animação", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "altura" }, @@ -6187,10 +6225,8 @@ "model_file": { "name": "arquivo_do_modelo" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "largura" } @@ -6285,13 +6321,11 @@ "description": "Carregue uma imagem da pasta de saída. Quando o botão de atualizar for clicado, o nó irá atualizar a lista de imagens e selecionar automaticamente a primeira imagem, facilitando a iteração.", "display_name": "Carregar Imagem (dos Resultados)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "imagem" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "escolher arquivo para enviar" } @@ -6393,6 +6427,60 @@ } } }, + "LoraLoaderBypass": { + "description": "Aplicar LoRA no modo bypass. Diferente do LoRA regular, isso não modifica os pesos do modelo – em vez disso, injeta o cálculo do LoRA durante a passagem direta. Útil para cenários de treinamento.", + "display_name": "Carregar LoRA (Bypass) (Para depuração)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "O modelo CLIP ao qual o LoRA será aplicado." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "O nome do LoRA." + }, + "model": { + "name": "model", + "tooltip": "O modelo de difusão ao qual o LoRA será aplicado." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "Quão fortemente modificar o modelo CLIP. Este valor pode ser negativo." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "Quão fortemente modificar o modelo de difusão. Este valor pode ser negativo." + } + }, + "outputs": { + "0": { + "tooltip": "O modelo de difusão modificado." + }, + "1": { + "tooltip": "O modelo CLIP modificado." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "Aplicar LoRA no modo bypass. Diferente do LoRA regular, isso não modifica os pesos do modelo – em vez disso, injeta o cálculo do LoRA durante a passagem direta. Útil para cenários de treinamento.", + "display_name": "Carregar LoRA (Bypass, Apenas Modelo) (para depuração)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "O modelo de difusão modificado." + } + } + }, "LoraLoaderModelOnly": { "description": "LoRAs são usados para modificar modelos de difusão e CLIP, alterando a forma como os latents são denoizados, como na aplicação de estilos. Vários nós LoRA podem ser conectados juntos.", "display_name": "LoraLoaderModelOnly", @@ -6761,6 +6849,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "Reilumine uma imagem com ajustes de iluminação e transferência de luz baseada em referência opcional.", + "display_name": "Magnific Reiluminar Imagem", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Opções de ajuste fino para controle avançado de iluminação." + }, + "change_background": { + "name": "change_background", + "tooltip": "Modifica o fundo com base no prompt/referência." + }, + "image": { + "name": "image", + "tooltip": "A imagem a ser reiluminada." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Restringe a liberdade de geração para se aproximar mais do original." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Intensidade da aplicação da transferência de luz." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Mantém a textura e os detalhes finos do original." + }, + "prompt": { + "name": "prompt", + "tooltip": "Orientação descritiva para iluminação. Suporta notação de ênfase (1-1.4)." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Imagem de referência opcional para transferir a iluminação." + }, + "style": { + "name": "style", + "tooltip": "Preferência de estilo de saída." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "Realce de pele para retratos com múltiplos modos de processamento.", + "display_name": "Magnific Realce de Pele em Imagem", + "inputs": { + "image": { + "name": "image", + "tooltip": "A imagem de retrato a ser realçada." + }, + "mode": { + "name": "mode", + "tooltip": "Modo de processamento: criativo para realce artístico, fiel para preservar a aparência original, flexível para otimização direcionada." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Nível de intensidade de nitidez." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Nível de intensidade de granulação inteligente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "Transfira o estilo de uma imagem de referência para sua imagem de entrada.", + "display_name": "Transferência de Estilo de Imagem Magnific", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "Seleção do mecanismo de processamento." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "Quando desativado, cada geração pode introduzir um grau de aleatoriedade, levando a resultados mais diversos." + }, + "flavor": { + "name": "flavor", + "tooltip": "Tipo de transferência de estilo." + }, + "image": { + "name": "image", + "tooltip": "A imagem para aplicar a transferência de estilo." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Ativar modo retrato para aprimoramentos faciais." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "A imagem de referência da qual extrair o estilo." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Mantém a estrutura da imagem original." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Porcentagem da intensidade do estilo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "Modifica a orientação para escalar mais na 'direção' do prompt positivo do que na diferença entre o prompt negativo.", "display_name": "Mahiro CFG", @@ -10377,10 +10585,8 @@ "PreviewAny": { "display_name": "Pré-visualizar como Texto", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "source" } @@ -11213,19 +11419,28 @@ } }, "ResizeImageMaskNode": { + "description": "Redimensione uma imagem ou mask usando vários métodos de escala.", "display_name": "Redimensionar Imagem/Máscara", "inputs": { "input": { "name": "entrada" }, "resize_type": { - "name": "tipo_de_redimensionamento" + "name": "tipo_de_redimensionamento", + "tooltip": "Selecione como redimensionar: por dimensões exatas, fator de escala, correspondendo a outra imagem, etc." }, - "resize_type_multiplier": { - "name": "multiplicador" + "resize_type_crop": { + "name": "cortar" + }, + "resize_type_height": { + "name": "altura" + }, + "resize_type_width": { + "name": "largura" }, "scale_method": { - "name": "método_de_escala" + "name": "método_de_escala", + "tooltip": "Algoritmo de interpolação. 'area' é melhor para reduzir, 'lanczos' para aumentar, 'nearest-exact' para pixel art." } }, "outputs": { @@ -13370,6 +13585,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: Imagem(ns) para Modelo (Pro)", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "face_count": { + "name": "número_de_faces" + }, + "generate_type": { + "name": "tipo_de_geração" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "imagem" + }, + "image_back": { + "name": "imagem_traseira" + }, + "image_left": { + "name": "imagem_esquerda" + }, + "image_right": { + "name": "imagem_direita" + }, + "model": { + "name": "modelo", + "tooltip": "A opção LowPoly não está disponível para o modelo `3.1`." + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "name": "arquivo_modelo", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: Texto para Modelo (Pro)", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "face_count": { + "name": "número_de_faces" + }, + "generate_type": { + "name": "tipo_de_geração" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "modelo", + "tooltip": "A opção LowPoly não está disponível para o modelo `3.1`." + }, + "prompt": { + "name": "prompt", + "tooltip": "Suporta até 1024 caracteres." + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "name": "arquivo_modelo", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13465,6 +13758,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "redimensionar_imagens_automaticamente" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "imagem1" + }, + "image2": { + "name": "imagem2" + }, + "image3": { + "name": "imagem3" + }, + "image_encoder": { + "name": "codificador_de_imagem" + }, + "prompt": { + "name": "prompt" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Texto para minúsculas", "inputs": { @@ -13670,6 +13997,10 @@ "name": "modo_bucket", "tooltip": "Ativar modo de bucket de resolução. Quando ativado, espera latents pré-bucketados do nó ResolutionBucket." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Ativar modo bypass para treinamento. Quando ativado, os adaptadores são aplicados via hooks de encaminhamento em vez de modificação de pesos. Útil para modelos quantizados onde os pesos não podem ser modificados diretamente." + }, "control_after_generate": { "name": "controlar após gerar" }, @@ -13742,10 +14073,6 @@ "2": { "name": "mapa_de_perda", "tooltip": "Histórico de perda" - }, - "3": { - "name": "passos", - "tooltip": "Total de passos de treinamento" } } }, @@ -15640,6 +15967,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "saída do codificador de áudio 1" + }, + "audio_scale": { + "name": "escala de áudio" + }, + "clip_vision_output": { + "name": "saída do clip vision" + }, + "height": { + "name": "altura" + }, + "length": { + "name": "duração" + }, + "mode": { + "name": "modo" + }, + "model": { + "name": "modelo" + }, + "model_patch": { + "name": "patch do modelo" + }, + "motion_frame_count": { + "name": "quantidade de quadros de movimento", + "tooltip": "Número de quadros anteriores a serem usados como contexto de movimento." + }, + "negative": { + "name": "negativo" + }, + "positive": { + "name": "positivo" + }, + "previous_frames": { + "name": "quadros anteriores" + }, + "start_image": { + "name": "imagem inicial" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "largura" + } + }, + "outputs": { + "0": { + "name": "modelo", + "tooltip": null + }, + "1": { + "name": "positivo", + "tooltip": null + }, + "2": { + "name": "negativo", + "tooltip": null + }, + "3": { + "name": "latente", + "tooltip": null + }, + "4": { + "name": "imagem recortada", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16156,6 +16556,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "Aprimorador de vídeo rápido e de alta qualidade que aumenta a resolução e restaura a nitidez de vídeos de baixa resolução ou borrados.", + "display_name": "FlashVSR Video Upscale", + "inputs": { + "target_resolution": { + "name": "resolução_alvo" + }, + "video": { + "name": "vídeo" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "Aumente a resolução e a qualidade da imagem, ampliando fotos para 4K ou 8K para resultados nítidos e detalhados.", + "display_name": "WaveSpeed Image Upscale", + "inputs": { + "image": { + "name": "imagem" + }, + "model": { + "name": "modelo" + }, + "target_resolution": { + "name": "resolução_alvo" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Captura da Webcam", "inputs": { @@ -16168,8 +16605,7 @@ "image": { "name": "imagem" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "largura" } diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index ec7108ca2..3ac0dec10 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Mostrar caixa de ferramentas de seleção" + "name": "Mostrar caixa de ferramentas de seleção", + "tooltip": "Exibe uma barra de ferramentas flutuante quando os nós estão selecionados, proporcionando acesso rápido a ações comuns." }, "Comfy_ConfirmClear": { "name": "Exigir confirmação ao limpar o fluxo de trabalho" @@ -147,7 +148,8 @@ "Linear": "Linear", "Spline": "Spline", "Straight": "Reto" - } + }, + "tooltip": "Controla a aparência e a visibilidade dos links de conexão entre os nós no canvas." }, "Comfy_Load3D_3DViewerEnable": { "name": "Ativar visualizador 3D (Beta)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Mostrar largura × altura abaixo da pré-visualização da imagem" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Sempre mostrar widgets avançados em todos os nodes", + "tooltip": "Quando ativado, os widgets avançados ficam sempre visíveis em todos os nodes, sem a necessidade de expandi-los individualmente." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Ajustar automaticamente o link ao slot do nó", "tooltip": "Ao arrastar um link sobre um nó, o link se ajusta automaticamente a um slot de entrada viável no nó" @@ -332,6 +338,10 @@ "name": "Tamanho do histórico da fila", "tooltip": "O número máximo de tarefas exibidas no histórico da fila." }, + "Comfy_Queue_QPOV2": { + "name": "Usar a fila de tarefas unificada no painel lateral de Assets", + "tooltip": "Substitui o painel flutuante de fila de tarefas por uma fila de tarefas equivalente incorporada ao painel lateral de Assets. Você pode desativar isso para voltar ao layout do painel flutuante." + }, "Comfy_Sidebar_Location": { "name": "Localização da barra lateral", "options": { @@ -466,6 +476,7 @@ "tooltip": "O deslocamento do ponto de controle bezier a partir do ponto central de redirecionamento" }, "pysssss_SnapToGrid": { - "name": "Sempre alinhar à grade" + "name": "Sempre alinhar à grade", + "tooltip": "Quando ativado, os nós se alinharão automaticamente à grade ao serem movidos ou redimensionados." } } diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 60155d334..b71738e27 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -24,6 +24,7 @@ "assets": "Ресурсы", "baseModels": "Базовые модели", "browseAssets": "Просмотр ресурсов", + "byType": "По типу", "checkpoints": "Чекпойнты", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "Пример:", @@ -45,6 +46,10 @@ "failed": "Ошибка загрузки", "inProgress": "Загрузка {assetName}..." }, + "emptyImported": { + "canImport": "Пока нет импортированных моделей. Нажмите «Импортировать модель», чтобы добавить свою.", + "restricted": "Персональные модели доступны только на уровне Creator и выше." + }, "errorFileTooLarge": "Файл превышает максимально допустимый размер", "errorFormatNotAllowed": "Разрешён только формат SafeTensor", "errorModelTypeNotSupported": "Этот тип модели не поддерживается", @@ -61,6 +66,7 @@ "finish": "Готово", "genericLinkPlaceholder": "Вставьте ссылку сюда", "importAnother": "Импортировать другой", + "imported": "Импортировано", "jobId": "ID задачи", "loadingModels": "Загрузка {type}...", "maxFileSize": "Максимальный размер файла: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3D-модель" }, "modelAssociatedWithLink": "Модель, связанная с предоставленной вами ссылкой:", + "modelInfo": { + "addBaseModel": "Добавить базовую модель...", + "addTag": "Добавить тег...", + "additionalTags": "Дополнительные теги", + "baseModelUnknown": "Базовая модель неизвестна", + "basicInfo": "Основная информация", + "compatibleBaseModels": "Совместимые базовые модели", + "description": "Описание", + "descriptionNotSet": "Описание не задано", + "descriptionPlaceholder": "Добавьте описание для этой модели...", + "displayName": "Отображаемое имя", + "editDisplayName": "Редактировать отображаемое имя", + "fileName": "Имя файла", + "modelDescription": "Описание модели", + "modelTagging": "Теги модели", + "modelType": "Тип модели", + "noAdditionalTags": "Нет дополнительных тегов", + "selectModelPrompt": "Выберите модель, чтобы увидеть её информацию", + "selectModelType": "Выберите тип модели...", + "source": "Источник", + "title": "Информация о модели", + "triggerPhrases": "Триггерные фразы", + "viewOnSource": "Посмотреть на {source}" + }, "modelName": "Имя модели", "modelNamePlaceholder": "Введите имя для этой модели", "modelTypeSelectorLabel": "Какой это тип модели?", @@ -238,6 +268,12 @@ "title": "Создать аккаунт" } }, + "boundingBox": { + "height": "Высота", + "width": "Ширина", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "Очистить рабочий процесс", "deleteBlueprint": "Удалить схему", @@ -678,6 +714,7 @@ "clearAll": "Очистить всё", "clearFilters": "Сбросить фильтры", "close": "Закрыть", + "closeDialog": "Закрыть диалог", "color": "Цвет", "comfy": "Comfy", "comfyOrgLogoAlt": "Логотип ComfyOrg", @@ -694,6 +731,7 @@ "control_before_generate": "управление до генерации", "copied": "Скопировано", "copy": "Копировать", + "copyAll": "Скопировать всё", "copyJobId": "Копировать ID задания", "copyToClipboard": "Скопировать в буфер обмена", "copyURL": "Скопировать URL", @@ -756,6 +794,8 @@ "goToNode": "Перейти к ноде", "graphNavigation": "Навигация по графу", "halfSpeed": "0.5x", + "hideLeftPanel": "Скрыть левую панель", + "hideRightPanel": "Скрыть правую панель", "icon": "Иконка", "imageFailedToLoad": "Не удалось загрузить изображение", "imagePreview": "Предварительный просмотр изображения - Используйте клавиши со стрелками для навигации между изображениями", @@ -797,6 +837,7 @@ "name": "Имя", "newFolder": "Новая папка", "next": "Далее", + "nightly": "NIGHTLY", "no": "Нет", "noAudioRecorded": "Аудио не записано", "noItems": "Нет элементов", @@ -811,6 +852,7 @@ "nodeSlotsError": "Ошибка слотов ноды", "nodeWidgetsError": "Ошибка виджетов ноды", "nodes": "Узлы", + "nodesCount": "{count} узлов | {count} узел | {count} узла", "nodesRunning": "запущено узлов", "none": "Нет", "nothingToCopy": "Нечего копировать", @@ -885,7 +927,9 @@ "selectedFile": "Выбранный файл", "setAsBackground": "Установить как фон", "settings": "Настройки", + "showLeftPanel": "Показать левую панель", "showReport": "Показать отчёт", + "showRightPanel": "Показать правую панель", "singleSelectDropdown": "Выпадающий список единичного выбора", "sort": "Сортировать", "source": "Источник", @@ -908,6 +952,7 @@ "updating": "Обновление", "upload": "Загрузить", "usageHint": "Подсказка по использованию", + "use": "Использовать", "user": "Пользователь", "versionMismatchWarning": "Предупреждение о несовместимости версий", "versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.", @@ -915,11 +960,10 @@ "videoPreview": "Предварительный просмотр видео - Используйте клавиши со стрелками для навигации между видео", "viewImageOfTotal": "Просмотр изображения {index} из {total}", "viewVideoOfTotal": "Просмотр видео {index} из {total}", - "vitePreloadErrorMessage": "Вышла новая версия приложения. Хотите перезагрузить?\nЕсли нет, некоторые части приложения могут работать некорректно.\nВы можете отказаться и сохранить свой прогресс перед перезагрузкой.", - "vitePreloadErrorTitle": "Доступна новая версия", "volume": "Громкость", "warning": "Предупреждение", - "workflow": "Рабочий процесс" + "workflow": "Рабочий процесс", + "you": "Вы" }, "graphCanvasMenu": { "fitView": "Подгонять под выделенные", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "Нет изображений для сравнения" }, + "imageCrop": { + "cropPreviewAlt": "Предпросмотр обрезки", + "loading": "Загрузка...", + "noInputImage": "Входное изображение не подключено" + }, "importFailed": { "copyError": "Ошибка копирования", "title": "Ошибка импорта" @@ -1606,17 +1655,25 @@ "title": "В этом рабочем процессе отсутствуют узлы" } }, + "nightly": { + "badge": { + "label": "Предварительная версия", + "tooltip": "Вы используете ночную версию ComfyUI. Пожалуйста, используйте кнопку обратной связи, чтобы поделиться своим мнением об этих функциях." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "3d_модели", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_для_тестирования", "advanced": "расширенный", "animation": "анимация", @@ -1841,6 +1900,7 @@ }, "groupSettings": "Настройки группы", "groups": "Группы", + "hideAdvancedInputsButton": "Скрыть расширенные параметры", "hideInput": "Скрыть вход", "info": "Информация", "inputs": "ВХОДЫ", @@ -2073,6 +2133,7 @@ "NodeLibrary": "Библиотека нод", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "Настройки уведомлений", + "Other": "Другое", "PLY": "PLY", "PlanCredits": "План и кредиты", "Pointer": "Указатель", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Vue Nodes", "VueNodes": "Vue узлы", "Window": "Окно", - "Workflow": "Рабочий процесс" + "Workflow": "Рабочий процесс", + "Workspace": "Рабочее пространство" }, "shape": { "CARD": "Карточка", @@ -2118,12 +2180,14 @@ "viewControls": "Управление видом" }, "sideToolbar": { + "activeJobStatus": "Активная задача: {status}", "assets": "Ассеты", "backToAssets": "Назад ко всем ассетам", "browseTemplates": "Просмотреть примеры шаблонов", "downloads": "Загрузки", "generatedAssetsHeader": "Сгенерированные ресурсы", "helpCenter": "Центр поддержки", + "importedAssetsHeader": "Импортированные ресурсы", "labels": { "assets": "Ассеты", "console": "Консоль", @@ -2168,6 +2232,7 @@ "queue": "Очередь", "queueProgressOverlay": { "activeJobs": "{count} активное задание | {count} активных задания | {count} активных заданий", + "activeJobsShort": "{count} активно | {count} активно", "activeJobsSuffix": "активных заданий", "cancelJobTooltip": "Отменить задание", "clearHistory": "Очистить историю очереди заданий", @@ -2256,9 +2321,15 @@ "beta": "БЕТА", "billedMonthly": "Оплата ежемесячно", "billedYearly": "{total} Оплата ежегодно", + "billingComingSoon": { + "message": "Скоро появится командная оплата. Вы сможете оформить подписку на тариф для вашего рабочего пространства с оплатой за каждого участника. Следите за обновлениями.", + "title": "Скоро будет" + }, + "cancelSubscription": "Отменить подписку", "changeTo": "Перейти на {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Логотип Comfy Cloud", + "contactOwnerToSubscribe": "Свяжитесь с владельцем рабочего пространства для оформления подписки", "contactUs": "Связаться с нами", "creditsRemainingThisMonth": "Кредитов осталось в этом месяце", "creditsRemainingThisYear": "Кредитов осталось в этом году", @@ -2271,6 +2342,7 @@ "haveQuestions": "Есть вопросы или интересует корпоративное решение?", "invoiceHistory": "История счетов", "learnMore": "Узнать больше", + "managePayment": "Управление оплатой", "managePlan": "Управление планом", "manageSubscription": "Управление подпиской", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "Подписаться на Comfy Cloud", "subscribeToRun": "Подписаться", "subscribeToRunFull": "Подписаться для запуска", + "subscriptionRequiredMessage": "Для запуска рабочих процессов в облаке участникам требуется подписка", "tierNameYearly": "{name} Ежегодно", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "Подробнее", "viewMoreDetailsPlans": "Подробнее о планах и ценах", "viewUsageHistory": "История использования", + "workspaceNotSubscribed": "Это рабочее пространство не имеет подписки", "yearly": "Ежегодно", "yearlyCreditsLabel": "Годовые кредиты", "yearlyDiscount": "СКИДКА 20%", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "Не удалось загрузить 3D-модель", "failedToPurchaseCredits": "Не удалось купить кредиты: {error}", "failedToQueue": "Не удалось поставить в очередь", + "failedToSaveDraft": "Не удалось сохранить черновик рабочего процесса", "failedToToggleCamera": "Не удалось переключить камеру", "failedToToggleGrid": "Не удалось переключить сетку", "failedToUpdateBackgroundColor": "Не удалось обновить цвет фона", @@ -2486,7 +2561,8 @@ "notSet": "Не задано", "provider": "Способ входа", "title": "Настройки пользователя", - "updatePassword": "Обновить пароль" + "updatePassword": "Обновить пароль", + "workspaceSettings": "Настройки рабочего пространства" }, "validation": { "descriptionRequired": "Описание обязательно", @@ -2577,6 +2653,9 @@ "saveWorkflow": "Сохранить рабочий процесс" }, "workspace": { + "addedToWorkspace": "Вы были добавлены в {workspaceName}", + "inviteAccepted": "Приглашение принято", + "inviteFailed": "Не удалось принять приглашение", "unsavedChanges": { "message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?", "title": "Несохранённые изменения" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "Рабочее пространство не найдено" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "Создать", + "message": "Рабочие пространства позволяют участникам использовать общий пул кредитов. После создания вы станете владельцем.", + "nameLabel": "Название рабочего пространства*", + "namePlaceholder": "Введите название рабочего пространства", + "title": "Создать новое рабочее пространство" + }, + "dashboard": { + "placeholder": "Настройки рабочего пространства" + }, + "deleteDialog": { + "message": "Все неиспользованные кредиты или несохранённые ресурсы будут потеряны. Это действие необратимо.", + "messageWithName": "Удалить «{name}»? Все неиспользованные кредиты или несохранённые ресурсы будут потеряны. Это действие необратимо.", + "title": "Удалить это рабочее пространство?" + }, + "editWorkspaceDialog": { + "nameLabel": "Название рабочего пространства", + "save": "Сохранить", + "title": "Редактировать детали рабочего пространства" + }, + "invite": "Пригласить", + "inviteLimitReached": "Достигнут лимит в 50 участников", + "inviteMember": "Пригласить участника", + "inviteMemberDialog": { + "createLink": "Создать ссылку", + "linkCopied": "Скопировано", + "linkCopyFailed": "Не удалось скопировать ссылку", + "linkStep": { + "copyLink": "Скопировать ссылку", + "done": "Готово", + "message": "Убедитесь, что его аккаунт использует этот email.", + "title": "Отправьте эту ссылку человеку" + }, + "message": "Создайте ссылку-приглашение для отправки человеку", + "placeholder": "Введите email человека", + "title": "Пригласить человека в это рабочее пространство" + }, + "leaveDialog": { + "leave": "Покинуть", + "message": "Вы не сможете присоединиться снова, если не свяжетесь с владельцем рабочего пространства.", + "title": "Покинуть это рабочее пространство?" + }, + "members": { + "actions": { + "copyLink": "Скопировать ссылку приглашения", + "removeMember": "Удалить участника", + "revokeInvite": "Отозвать приглашение" + }, + "columns": { + "expiryDate": "Дата истечения", + "inviteDate": "Дата приглашения", + "joinDate": "Дата вступления" + }, + "createNewWorkspace": "создайте новое.", + "membersCount": "{count}/50 участников", + "noInvites": "Нет ожидающих приглашений", + "noMembers": "Нет участников", + "pendingInvitesCount": "{count} ожидающее приглашение | {count} ожидающих приглашений", + "personalWorkspaceMessage": "Вы не можете приглашать других участников в ваше личное рабочее пространство. Чтобы добавить участников,", + "tabs": { + "active": "Активные", + "pendingCount": "Ожидают ({count})" + } + }, + "menu": { + "deleteWorkspace": "Удалить рабочее пространство", + "deleteWorkspaceDisabledTooltip": "Сначала отмените активную подписку рабочего пространства", + "editWorkspace": "Редактировать детали рабочего пространства", + "leaveWorkspace": "Покинуть рабочее пространство" + }, + "removeMemberDialog": { + "error": "Не удалось удалить участника", + "message": "Этот участник будет удалён из вашего рабочего пространства. Использованные им кредиты не будут возвращены.", + "remove": "Удалить участника", + "success": "Участник удалён", + "title": "Удалить этого участника?" + }, + "revokeInviteDialog": { + "message": "Этот человек больше не сможет присоединиться к вашему рабочему пространству. Его ссылка-приглашение будет аннулирована.", + "revoke": "Отозвать приглашение", + "title": "Отозвать приглашение?" + }, + "tabs": { + "dashboard": "Панель управления", + "membersCount": "Участники ({count})", + "planCredits": "Тариф и кредиты" + }, + "toast": { + "failedToCreateWorkspace": "Не удалось создать рабочее пространство", + "failedToDeleteWorkspace": "Не удалось удалить рабочее пространство", + "failedToFetchWorkspaces": "Не удалось загрузить рабочие пространства", + "failedToLeaveWorkspace": "Не удалось покинуть рабочее пространство", + "failedToUpdateWorkspace": "Не удалось обновить рабочее пространство", + "workspaceCreated": { + "message": "Оформите подписку, пригласите коллег и начните совместную работу.", + "subscribe": "Оформить подписку", + "title": "Рабочее пространство создано" + }, + "workspaceDeleted": { + "message": "Рабочее пространство было безвозвратно удалено.", + "title": "Рабочее пространство удалено" + }, + "workspaceLeft": { + "message": "Вы вышли из рабочего пространства.", + "title": "Вы покинули рабочее пространство" + }, + "workspaceUpdated": { + "message": "Детали рабочего пространства сохранены.", + "title": "Рабочее пространство обновлено" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "Создать новое рабочее пространство", + "maxWorkspacesReached": "Вы можете владеть только 10 рабочими пространствами. Удалите одно, чтобы создать новое.", + "personal": "Личное", + "roleMember": "Участник", + "roleOwner": "Владелец", + "subscribe": "Оформить подписку", + "switchWorkspace": "Сменить рабочее пространство" + }, "zoomControls": { "hideMinimap": "Скрыть миникарту", "label": "Управление масштабом", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index e8c555f7e..4563e3e39 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Редактирование изображений с помощью новейшей модели Bria", + "display_name": "Bria Image Edit", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "guidance_scale": { + "name": "guidance_scale", + "tooltip": "Большее значение заставляет изображение точнее следовать промпту." + }, + "image": { + "name": "изображение" + }, + "mask": { + "name": "маска", + "tooltip": "Если не указано, редактирование применяется ко всему изображению." + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "модерация", + "tooltip": "Настройки модерации" + }, + "moderation_prompt_content_moderation": { + "name": "модерация_контента_промпта" + }, + "moderation_visual_input_moderation": { + "name": "модерация_визуального_входа" + }, + "moderation_visual_output_moderation": { + "name": "модерация_визуального_выхода" + }, + "negative_prompt": { + "name": "негативный_промпт" + }, + "prompt": { + "name": "промпт", + "tooltip": "Инструкция для редактирования изображения" + }, + "seed": { + "name": "seed" + }, + "steps": { + "name": "шаги" + }, + "structured_prompt": { + "name": "структурированный_промпт", + "tooltip": "Строка, содержащая структурированный промпт для редактирования в формате JSON. Используйте вместо обычного промпта для точного, программного управления." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "структурированный_промпт", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Создать видео с использованием промпта и первого и последнего кадров.", "display_name": "ByteDance - Преобразование первого-последнего кадра в видео", @@ -351,6 +413,10 @@ "name": "first_frame", "tooltip": "Первый кадр, который будет использоваться для видео." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "last_frame": { "name": "last_frame", "tooltip": "Последний кадр, который будет использоваться для видео." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "Редактирование изображений с использованием моделей ByteDance через API на основе промпта", - "display_name": "Редактирование изображений ByteDance", - "inputs": { - "control_after_generate": { - "name": "control after generate" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "Более высокое значение заставляет изображение точнее следовать промпту" - }, - "image": { - "name": "image", - "tooltip": "Базовое изображение для редактирования" - }, - "model": { - "name": "model" - }, - "prompt": { - "name": "prompt", - "tooltip": "Инструкция для редактирования изображения" - }, - "seed": { - "name": "seed", - "tooltip": "Сид для использования при генерации" - }, - "watermark": { - "name": "watermark", - "tooltip": "Добавлять ли водяной знак \"Сгенерировано ИИ\" на изображение" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "Генерация изображений с использованием моделей ByteDance через API на основе промпта", "display_name": "Изображение ByteDance", @@ -527,6 +556,10 @@ "name": "duration", "tooltip": "Продолжительность видео в секундах." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "image": { "name": "image", "tooltip": "Первый кадр, который будет использоваться для видео." @@ -634,6 +667,10 @@ "name": "длительность", "tooltip": "Длительность выходного видео в секундах." }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "Этот параметр игнорируется для всех моделей, кроме seedance-1-5-pro." + }, "model": { "name": "модель" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "выбор" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "ИНДЕКС", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "Загрузить модель ControlNet (дифф)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "Загрузить 3D", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "высота" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "файл модели" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "ширина" } @@ -6270,13 +6306,11 @@ "description": "Загрузите изображение из папки вывода. При нажатии кнопки обновления, узел обновит список изображений и автоматически выберет первое изображение, что позволяет легко итерировать.", "display_name": "Загрузить изображение (из выходных данных)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "изображение" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "выберите файл для загрузки" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "Применяет LoRA в режиме обхода. В отличие от обычной LoRA, веса модели не изменяются — вычисления LoRA внедряются во время прямого прохода. Полезно для сценариев обучения.", + "display_name": "Загрузка LoRA (Обход) (для отладки)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "Модель CLIP, к которой будет применяться LoRA." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "Имя LoRA." + }, + "model": { + "name": "model", + "tooltip": "Диффузионная модель, к которой будет применяться LoRA." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "Степень изменения модели CLIP. Может быть отрицательным значением." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "Степень изменения диффузионной модели. Может быть отрицательным значением." + } + }, + "outputs": { + "0": { + "tooltip": "Изменённая диффузионная модель." + }, + "1": { + "tooltip": "Изменённая модель CLIP." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "Применяет LoRA в режиме обхода. В отличие от обычной LoRA, веса модели не изменяются — вычисления LoRA внедряются во время прямого прохода. Полезно для сценариев обучения.", + "display_name": "Загрузка LoRA (Обход, только модель) (для отладки)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "Изменённая диффузионная модель." + } + } + }, "LoraLoaderModelOnly": { "description": "LoRA используются для изменения моделей диффузии и CLIP, изменяя способ, которым латенты удаляются от шума, например, применяя стили. Несколько нод LoRA могут быть связаны вместе.", "display_name": "Загрузчик LoRA (Только модель)", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "Изменение освещения изображения с возможностью передачи света по ссылке.", + "display_name": "Magnific: Пересвет изображения", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Тонкая настройка для продвинутого управления освещением." + }, + "change_background": { + "name": "change_background", + "tooltip": "Изменяет фон на основе промпта или референса." + }, + "image": { + "name": "image", + "tooltip": "Изображение для пересвета." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Ограничивает свободу генерации для большего соответствия оригиналу." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Интенсивность применения передачи света." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Сохраняет текстуру и мелкие детали оригинала." + }, + "prompt": { + "name": "prompt", + "tooltip": "Описание для управления освещением. Поддерживает нотацию акцентов (1-1.4)." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Необязательное референсное изображение для передачи освещения." + }, + "style": { + "name": "style", + "tooltip": "Предпочтительный стиль результата." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "Улучшение кожи на портретах с несколькими режимами обработки.", + "display_name": "Magnific: Улучшение кожи на изображении", + "inputs": { + "image": { + "name": "image", + "tooltip": "Портрет для улучшения." + }, + "mode": { + "name": "mode", + "tooltip": "Режим обработки: creative — для художественного улучшения, faithful — для сохранения оригинального вида, flexible — для целевой оптимизации." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Уровень резкости." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Уровень интеллектуального зерна." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "Переносите стиль с эталонного изображения на ваше входное изображение.", + "display_name": "Magnific Перенос стиля изображения", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "Выбор движка обработки." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "Если отключено, каждое новое поколение будет содержать элемент случайности, что приведёт к более разнообразным результатам." + }, + "flavor": { + "name": "flavor", + "tooltip": "Вариант переноса стиля." + }, + "image": { + "name": "image", + "tooltip": "Изображение, к которому будет применён перенос стиля." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Включить портретный режим для улучшения лиц." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Эталонное изображение, из которого будет извлекаться стиль." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Сохраняет структуру исходного изображения." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Процент силы стиля." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "Измените руководство, чтобы оно больше масштабировалось в «направлении» положительной подсказки, а не разницы между отрицательной подсказкой.", "display_name": "Mahiro настолько мила, что заслуживает лучшей функции руководства!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "Предпросмотр любого", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "источник" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "Изменить размер изображения или mask с помощью различных методов масштабирования.", "display_name": "Изменить размер изображения/маски", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "Выберите способ изменения размера: по точным размерам, коэффициенту масштабирования, подгонке под другое изображение и т.д." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "обрезка" + }, + "resize_type_height": { + "name": "высота" + }, + "resize_type_width": { + "name": "ширина" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "Алгоритм интерполяции. 'area' лучше всего подходит для уменьшения, 'lanczos' — для увеличения, 'nearest-exact' — для пиксель-арта." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: Изображение(я) в модель (Pro)", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "face_count": { + "name": "количество граней" + }, + "generate_type": { + "name": "тип генерации" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "изображение" + }, + "image_back": { + "name": "изображение сзади" + }, + "image_left": { + "name": "изображение слева" + }, + "image_right": { + "name": "изображение справа" + }, + "model": { + "name": "модель", + "tooltip": "Опция LowPoly недоступна для модели `3.1`." + }, + "seed": { + "name": "seed", + "tooltip": "Seed управляет тем, будет ли узел запускаться повторно; результаты всегда недетерминированы, независимо от seed." + } + }, + "outputs": { + "0": { + "name": "файл_модели", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: Текст в модель (Pro)", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "face_count": { + "name": "количество граней" + }, + "generate_type": { + "name": "тип генерации" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "модель", + "tooltip": "Опция LowPoly недоступна для модели `3.1`." + }, + "prompt": { + "name": "промпт", + "tooltip": "Поддерживается до 1024 символов." + }, + "seed": { + "name": "seed", + "tooltip": "Seed управляет тем, будет ли узел запускаться повторно; результаты всегда недетерминированы, независимо от seed." + } + }, + "outputs": { + "0": { + "name": "файл_модели", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "автоматическое_изменение_размера_изображений" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "изображение1" + }, + "image2": { + "name": "изображение2" + }, + "image3": { + "name": "изображение3" + }, + "image_encoder": { + "name": "кодировщик_изображения" + }, + "prompt": { + "name": "промпт" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Преобразовать текст в нижний регистр", "inputs": { @@ -13643,6 +13970,10 @@ "name": "режим корзины разрешений", "tooltip": "Включить режим корзины разрешений. При включении ожидает предварительно разбитые латенты от узла ResolutionBucket." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Включить режим обхода для обучения. При включении адаптеры применяются через forward hooks вместо изменения весов. Полезно для квантизованных моделей, где веса нельзя изменить напрямую." + }, "control_after_generate": { "name": "управление после генерации" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "выход аудиоэнкодера 1" + }, + "audio_scale": { + "name": "масштаб аудио" + }, + "clip_vision_output": { + "name": "выход clip vision" + }, + "height": { + "name": "высота" + }, + "length": { + "name": "длина" + }, + "mode": { + "name": "режим" + }, + "model": { + "name": "модель" + }, + "model_patch": { + "name": "патч модели" + }, + "motion_frame_count": { + "name": "количество кадров движения", + "tooltip": "Количество предыдущих кадров, используемых как контекст движения." + }, + "negative": { + "name": "негативный" + }, + "positive": { + "name": "позитивный" + }, + "previous_frames": { + "name": "предыдущие кадры" + }, + "start_image": { + "name": "стартовое изображение" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "ширина" + } + }, + "outputs": { + "0": { + "name": "модель", + "tooltip": null + }, + "1": { + "name": "позитивный", + "tooltip": null + }, + "2": { + "name": "негативный", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "обрезанное изображение", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "Быстрый и качественный апскейлер видео, повышающий разрешение и восстанавливающий чёткость для низкокачественных или размытых кадров.", + "display_name": "FlashVSR Видео Апскейл", + "inputs": { + "target_resolution": { + "name": "целевое_разрешение" + }, + "video": { + "name": "видео" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "Увеличьте разрешение и качество изображения, апскейл фотографий до 4K или 8K для чётких и детализированных результатов.", + "display_name": "WaveSpeed Апскейл Изображения", + "inputs": { + "image": { + "name": "изображение" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "целевое_разрешение" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Захват с веб-камеры", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "изображение" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "ширина" } diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 99f123856..6023161bb 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Показать панель инструментов выбора" + "name": "Показать панель инструментов выбора", + "tooltip": "Отображает плавающую панель инструментов при выборе узлов, предоставляя быстрый доступ к основным действиям." }, "Comfy_ConfirmClear": { "name": "Требовать подтверждение при очистке рабочего процесса" @@ -147,7 +148,8 @@ "Linear": "Линейный", "Spline": "Сплайн", "Straight": "Прямой" - } + }, + "tooltip": "Управляет внешним видом и видимостью соединительных линий между узлами на холсте." }, "Comfy_Load3D_3DViewerEnable": { "name": "Включить 3D-просмотрщик (Бета)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Показывать ширину × высоту под предварительным просмотром изображения" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Всегда показывать расширенные виджеты на всех узлах", + "tooltip": "Если включено, расширенные виджеты всегда видны на всех узлах без необходимости раскрывать их по отдельности." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Автоматически привязывать ссылку к слоту ноды", "tooltip": "При перетаскивании ссылки над нодой ссылка автоматически привязывается к подходящему входному слоту ноды" @@ -332,6 +338,10 @@ "name": "Размер истории очереди", "tooltip": "Максимальное количество задач, отображаемых в истории очереди." }, + "Comfy_Queue_QPOV2": { + "name": "Использовать объединённую очередь заданий в боковой панели ресурсов", + "tooltip": "Заменяет плавающую панель очереди заданий на аналогичную очередь, встроенную в боковую панель ресурсов. Вы можете отключить эту опцию, чтобы вернуться к плавающей панели." + }, "Comfy_Sidebar_Location": { "name": "Расположение боковой панели", "options": { @@ -466,6 +476,7 @@ "tooltip": "Смещение контрольной точки Безье от центральной точки перераспределения" }, "pysssss_SnapToGrid": { - "name": "Всегда привязываться к сетке" + "name": "Всегда привязываться к сетке", + "tooltip": "Если включено, узлы будут автоматически выравниваться по сетке при перемещении или изменении размера." } } diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 870cee220..098339030 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -24,6 +24,7 @@ "assets": "Varlıklar", "baseModels": "Temel modeller", "browseAssets": "Varlıklara Göz At", + "byType": "Türe göre", "checkpoints": "Kontrol noktaları", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "Örnek:", @@ -45,6 +46,10 @@ "failed": "İndirme başarısız oldu", "inProgress": "{assetName} indiriliyor..." }, + "emptyImported": { + "canImport": "Henüz içe aktarılmış model yok. Kendi modelinizi eklemek için \"Model İçe Aktar\"a tıklayın.", + "restricted": "Kişisel modeller yalnızca Creator ve üzeri seviyelerde kullanılabilir." + }, "errorFileTooLarge": "Dosya izin verilen maksimum boyut sınırını aşıyor", "errorFormatNotAllowed": "Yalnızca SafeTensor formatı destekleniyor", "errorModelTypeNotSupported": "Bu model türü desteklenmiyor", @@ -61,6 +66,7 @@ "finish": "Bitir", "genericLinkPlaceholder": "Bağlantıyı buraya yapıştırın", "importAnother": "Başka Birini İçe Aktar", + "imported": "İçe aktarıldı", "jobId": "İş ID", "loadingModels": "{type} yükleniyor...", "maxFileSize": "Maksimum dosya boyutu: {size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3D Model" }, "modelAssociatedWithLink": "Sağladığınız bağlantı ile ilişkili model:", + "modelInfo": { + "addBaseModel": "Taban model ekle...", + "addTag": "Etiket ekle...", + "additionalTags": "Ek Etiketler", + "baseModelUnknown": "Taban model bilinmiyor", + "basicInfo": "Temel Bilgiler", + "compatibleBaseModels": "Uyumlu Taban Modelleri", + "description": "Açıklama", + "descriptionNotSet": "Açıklama ayarlanmadı", + "descriptionPlaceholder": "Bu model için bir açıklama ekleyin...", + "displayName": "Görünen Ad", + "editDisplayName": "Görünen Adı Düzenle", + "fileName": "Dosya Adı", + "modelDescription": "Model Açıklaması", + "modelTagging": "Model Etiketleme", + "modelType": "Model Türü", + "noAdditionalTags": "Ek etiket yok", + "selectModelPrompt": "Bilgilerini görmek için bir model seçin", + "selectModelType": "Model türü seç...", + "source": "Kaynak", + "title": "Model Bilgisi", + "triggerPhrases": "Tetikleyici İfadeler", + "viewOnSource": "{source} üzerinde görüntüle" + }, "modelName": "Model Adı", "modelNamePlaceholder": "Bu model için bir ad girin", "modelTypeSelectorLabel": "Bu hangi model türü?", @@ -238,6 +268,12 @@ "title": "Hesap oluşturun" } }, + "boundingBox": { + "height": "Yükseklik", + "width": "Genişlik", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "İş Akışını Temizle", "deleteBlueprint": "Taslağı Sil", @@ -678,6 +714,7 @@ "clearAll": "Tümünü temizle", "clearFilters": "Filtreleri Temizle", "close": "Kapat", + "closeDialog": "Diyaloğu kapat", "color": "Renk", "comfy": "Comfy", "comfyOrgLogoAlt": "ComfyOrg Logosu", @@ -694,6 +731,7 @@ "control_before_generate": "oluşturmadan önce kontrol et", "copied": "Kopyalandı", "copy": "Kopyala", + "copyAll": "Tümünü Kopyala", "copyJobId": "İş Kimliğini Kopyala", "copyToClipboard": "Panoya Kopyala", "copyURL": "URL'yi Kopyala", @@ -756,6 +794,8 @@ "goToNode": "Düğüme Git", "graphNavigation": "Grafik gezintisi", "halfSpeed": "0.5x", + "hideLeftPanel": "Sol paneli gizle", + "hideRightPanel": "Sağ paneli gizle", "icon": "Simge", "imageFailedToLoad": "Görsel yüklenemedi", "imagePreview": "Görüntü önizlemesi - Görüntüler arasında gezinmek için ok tuşlarını kullanın", @@ -797,6 +837,7 @@ "name": "Ad", "newFolder": "Yeni Klasör", "next": "İleri", + "nightly": "NIGHTLY", "no": "Hayır", "noAudioRecorded": "Ses kaydedilmedi", "noItems": "Öğe yok", @@ -811,6 +852,7 @@ "nodeSlotsError": "Düğüm Yuva Hatası", "nodeWidgetsError": "Düğüm Widget Hatası", "nodes": "Düğümler", + "nodesCount": "{count} düğüm | {count} düğüm | {count} düğüm", "nodesRunning": "düğüm çalışıyor", "none": "Hiçbiri", "nothingToCopy": "Kopyalanacak bir şey yok", @@ -885,7 +927,9 @@ "selectedFile": "Seçilen dosya", "setAsBackground": "Arka Plan Olarak Ayarla", "settings": "Ayarlar", + "showLeftPanel": "Sol paneli göster", "showReport": "Raporu Göster", + "showRightPanel": "Sağ paneli göster", "singleSelectDropdown": "Tekli seçim açılır menüsü", "sort": "Sırala", "source": "Kaynak", @@ -908,6 +952,7 @@ "updating": "{id} güncelleniyor", "upload": "Yükle", "usageHint": "Kullanım ipucu", + "use": "Kullan", "user": "Kullanıcı", "versionMismatchWarning": "Sürüm Uyumluluk Uyarısı", "versionMismatchWarningMessage": "{warning}: {detail} Güncelleme talimatları için https://docs.comfy.org/installation/update_comfyui#common-update-issues adresini ziyaret edin.", @@ -915,11 +960,10 @@ "videoPreview": "Video önizlemesi - Videolar arasında gezinmek için ok tuşlarını kullanın", "viewImageOfTotal": "{total} görüntüden {index}. görüntüyü görüntüle", "viewVideoOfTotal": "{total} videodan {index}. videoyu görüntüle", - "vitePreloadErrorMessage": "Uygulamanın yeni bir sürümü yayınlandı. Yeniden yüklemek ister misiniz?\nEğer yüklemezseniz, uygulamanın bazı bölümleri beklenildiği gibi çalışmayabilir.\nİlerlemenizi kaydettikten sonra yeniden yüklemek için çekinmeyin.", - "vitePreloadErrorTitle": "Yeni Sürüm Mevcut", "volume": "Ses", "warning": "Uyarı", - "workflow": "İş Akışı" + "workflow": "İş Akışı", + "you": "Sen" }, "graphCanvasMenu": { "fitView": "Görünüme Sığdır", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "Karşılaştırılacak görsel yok" }, + "imageCrop": { + "cropPreviewAlt": "Kırpma önizlemesi", + "loading": "Yükleniyor...", + "noInputImage": "Bağlı giriş görseli yok" + }, "importFailed": { "copyError": "Kopyalama Hatası", "title": "İçe Aktarma Başarısız" @@ -1606,17 +1655,25 @@ "title": "Bu iş akışında eksik düğümler var" } }, + "nightly": { + "badge": { + "label": "Önizleme Sürümü", + "tooltip": "ComfyUI'nin nightly sürümünü kullanıyorsunuz. Lütfen bu özelliklerle ilgili görüşlerinizi paylaşmak için geri bildirim butonunu kullanın." + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "3d_modeller", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "ByteDance", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_test_için", "advanced": "gelişmiş", "animation": "animasyon", @@ -1841,6 +1900,7 @@ }, "groupSettings": "Grup Ayarları", "groups": "Gruplar", + "hideAdvancedInputsButton": "Gelişmiş girişleri gizle", "hideInput": "Girdiyi gizle", "info": "Bilgi", "inputs": "GİRİŞLER", @@ -2073,6 +2133,7 @@ "NodeLibrary": "Düğüm Kütüphanesi", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "Bildirim Tercihleri", + "Other": "Diğer", "PLY": "PLY", "PlanCredits": "Plan & Krediler", "Pointer": "İşaretçi", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Vue Düğümleri", "VueNodes": "Vue Düğümleri", "Window": "Pencere", - "Workflow": "İş Akışı" + "Workflow": "İş Akışı", + "Workspace": "Çalışma Alanı" }, "shape": { "CARD": "Kart", @@ -2118,12 +2180,14 @@ "viewControls": "Görünüm Kontrolleri" }, "sideToolbar": { + "activeJobStatus": "Aktif iş: {status}", "assets": "Varlıklar", "backToAssets": "Tüm varlıklara dön", "browseTemplates": "Örnek şablonlara göz atın", "downloads": "İndirmeler", "generatedAssetsHeader": "Oluşturulan varlıklar", "helpCenter": "Yardım Merkezi", + "importedAssetsHeader": "İçe aktarılan varlıklar", "labels": { "assets": "Varlıklar", "console": "Konsol", @@ -2168,6 +2232,7 @@ "queue": "Kuyruk", "queueProgressOverlay": { "activeJobs": "{count} aktif iş | {count} aktif iş", + "activeJobsShort": "{count} aktif | {count} aktif", "activeJobsSuffix": "aktif iş", "cancelJobTooltip": "İşi iptal et", "clearHistory": "İş kuyruğu geçmişini temizle", @@ -2256,9 +2321,15 @@ "beta": "BETA", "billedMonthly": "Aylık faturalandırılır", "billedYearly": "{total} Yıllık faturalandırılır", + "billingComingSoon": { + "message": "Takım faturalandırması yakında geliyor. Çalışma alanınız için koltuk başına fiyatlandırma ile bir plana abone olabileceksiniz. Güncellemeler için bizi takip edin.", + "title": "Yakında" + }, + "cancelSubscription": "Aboneliği İptal Et", "changeTo": "{plan} planına geç", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud Logosu", + "contactOwnerToSubscribe": "Abone olmak için çalışma alanı sahibiyle iletişime geçin", "contactUs": "Bize ulaşın", "creditsRemainingThisMonth": "Bu ay kalan krediler", "creditsRemainingThisYear": "Bu yıl kalan krediler", @@ -2271,6 +2342,7 @@ "haveQuestions": "Sorularınız mı var veya kurumsal çözüm mü arıyorsunuz?", "invoiceHistory": "Fatura geçmişi", "learnMore": "Daha fazla bilgi edinin", + "managePayment": "Ödemeyi Yönet", "managePlan": "Planı yönet", "manageSubscription": "Aboneliği yönet", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "Comfy Cloud'a Abone Ol", "subscribeToRun": "Abone Ol", "subscribeToRunFull": "Çalıştırmaya Abone Ol", + "subscriptionRequiredMessage": "Üyelerin Bulut'ta iş akışlarını çalıştırabilmesi için abonelik gereklidir", "tierNameYearly": "{name} Yıllık", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "Daha fazla detay görüntüle", "viewMoreDetailsPlans": "Planlar ve fiyatlandırma hakkında daha fazla detay", "viewUsageHistory": "Kullanım geçmişini görüntüle", + "workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil", "yearly": "Yıllık", "yearlyCreditsLabel": "Toplam yıllık krediler", "yearlyDiscount": "%20 İNDİRİM", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "3B model yüklenemedi", "failedToPurchaseCredits": "Kredi satın alınamadı: {error}", "failedToQueue": "Kuyruğa alınamadı", + "failedToSaveDraft": "Çalışma taslağı kaydedilemedi", "failedToToggleCamera": "Kamera açılıp kapatılamadı", "failedToToggleGrid": "Izgara açılıp kapatılamadı", "failedToUpdateBackgroundColor": "Arka plan rengi güncellenemedi", @@ -2486,7 +2561,8 @@ "notSet": "Ayarlanmadı", "provider": "Giriş Sağlayıcı", "title": "Kullanıcı Ayarları", - "updatePassword": "Şifreyi Güncelle" + "updatePassword": "Şifreyi Güncelle", + "workspaceSettings": "Çalışma alanı ayarları" }, "validation": { "descriptionRequired": "Açıklama gerekli", @@ -2577,6 +2653,9 @@ "saveWorkflow": "İş akışını kaydet" }, "workspace": { + "addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz", + "inviteAccepted": "Davet kabul edildi", + "inviteFailed": "Davet kabul edilemedi", "unsavedChanges": { "message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?", "title": "Kaydedilmemiş Değişiklikler" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "Çalışma alanı bulunamadı" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "Oluştur", + "message": "Çalışma alanları, üyelerin tek bir kredi havuzunu paylaşmasını sağlar. Oluşturduktan sonra sahibi olacaksınız.", + "nameLabel": "Çalışma alanı adı*", + "namePlaceholder": "Çalışma alanı adını girin", + "title": "Yeni bir çalışma alanı oluştur" + }, + "dashboard": { + "placeholder": "Çalışma alanı kontrol paneli ayarları" + }, + "deleteDialog": { + "message": "Kullanılmamış krediler veya kaydedilmemiş varlıklar kaybolacak. Bu işlem geri alınamaz.", + "messageWithName": "\"{name}\" silinsin mi? Kullanılmamış krediler veya kaydedilmemiş varlıklar kaybolacak. Bu işlem geri alınamaz.", + "title": "Bu çalışma alanı silinsin mi?" + }, + "editWorkspaceDialog": { + "nameLabel": "Çalışma alanı adı", + "save": "Kaydet", + "title": "Çalışma alanı detaylarını düzenle" + }, + "invite": "Davet Et", + "inviteLimitReached": "Maksimum 50 üye sınırına ulaştınız", + "inviteMember": "Üye Davet Et", + "inviteMemberDialog": { + "createLink": "Bağlantı oluştur", + "linkCopied": "Kopyalandı", + "linkCopyFailed": "Bağlantı kopyalanamadı", + "linkStep": { + "copyLink": "Bağlantıyı Kopyala", + "done": "Tamamlandı", + "message": "Hesaplarının bu e-posta adresini kullandığından emin olun.", + "title": "Bu bağlantıyı kişiye gönderin" + }, + "message": "Birine göndermek için paylaşılabilir bir davet bağlantısı oluştur", + "placeholder": "Kişinin e-posta adresini girin", + "title": "Bu çalışma alanına birini davet et" + }, + "leaveDialog": { + "leave": "Ayrıl", + "message": "Çalışma alanı sahibiyle iletişime geçmedikçe tekrar katılamazsınız.", + "title": "Bu çalışma alanından ayrılsın mı?" + }, + "members": { + "actions": { + "copyLink": "Davet bağlantısını kopyala", + "removeMember": "Üyeyi kaldır", + "revokeInvite": "Daveti geri al" + }, + "columns": { + "expiryDate": "Son kullanma tarihi", + "inviteDate": "Davet tarihi", + "joinDate": "Katılma tarihi" + }, + "createNewWorkspace": "yeni bir tane oluşturun.", + "membersCount": "{count}/50 Üye", + "noInvites": "Bekleyen davet yok", + "noMembers": "Üye yok", + "pendingInvitesCount": "{count} bekleyen davet | {count} bekleyen davet", + "personalWorkspaceMessage": "Şu anda kişisel çalışma alanınıza başka üyeler davet edemezsiniz. Bir çalışma alanına üye eklemek için,", + "tabs": { + "active": "Aktif", + "pendingCount": "Bekliyor ({count})" + } + }, + "menu": { + "deleteWorkspace": "Çalışma Alanını Sil", + "deleteWorkspaceDisabledTooltip": "Önce çalışma alanınızın aktif aboneliğini iptal edin", + "editWorkspace": "Çalışma alanı detaylarını düzenle", + "leaveWorkspace": "Çalışma Alanından Ayrıl" + }, + "removeMemberDialog": { + "error": "Üye kaldırılamadı", + "message": "Bu üye çalışma alanınızdan kaldırılacak. Kullandıkları krediler iade edilmeyecek.", + "remove": "Üyeyi kaldır", + "success": "Üye kaldırıldı", + "title": "Bu üye kaldırılsın mı?" + }, + "revokeInviteDialog": { + "message": "Bu üye artık çalışma alanınıza katılamayacak. Davet bağlantısı geçersiz olacak.", + "revoke": "Davet Etme", + "title": "Bu kişiyi davet etme?" + }, + "tabs": { + "dashboard": "Kontrol Paneli", + "membersCount": "Üyeler ({count})", + "planCredits": "Plan & Kredi" + }, + "toast": { + "failedToCreateWorkspace": "Çalışma alanı oluşturulamadı", + "failedToDeleteWorkspace": "Çalışma alanı silinemedi", + "failedToFetchWorkspaces": "Çalışma alanları yüklenemedi", + "failedToLeaveWorkspace": "Çalışma alanından ayrılamadı", + "failedToUpdateWorkspace": "Çalışma alanı güncellenemedi", + "workspaceCreated": { + "message": "Bir plana abone olun, ekip arkadaşlarınızı davet edin ve iş birliğine başlayın.", + "subscribe": "Abone Ol", + "title": "Çalışma alanı oluşturuldu" + }, + "workspaceDeleted": { + "message": "Çalışma alanı kalıcı olarak silindi.", + "title": "Çalışma alanı silindi" + }, + "workspaceLeft": { + "message": "Çalışma alanından ayrıldınız.", + "title": "Çalışma alanından ayrıldınız" + }, + "workspaceUpdated": { + "message": "Çalışma alanı detayları kaydedildi.", + "title": "Çalışma alanı güncellendi" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "Yeni çalışma alanı oluştur", + "maxWorkspacesReached": "Yalnızca 10 çalışma alanına sahip olabilirsiniz. Yeni bir tane oluşturmak için birini silin.", + "personal": "Kişisel", + "roleMember": "Üye", + "roleOwner": "Sahip", + "subscribe": "Abone Ol", + "switchWorkspace": "Çalışma alanı değiştir" + }, "zoomControls": { "hideMinimap": "Mini Haritayı Gizle", "label": "Yakınlaştırma Kontrolleri", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 33863da69..a0c2a4ea0 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "Bria'nın en son modeliyle görüntüleri düzenleyin", + "display_name": "Bria Görüntü Düzenleme", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "guidance_scale": { + "name": "yönlendirme_ölçeği", + "tooltip": "Daha yüksek değer, görüntünün isteme daha yakın olmasını sağlar." + }, + "image": { + "name": "görüntü" + }, + "mask": { + "name": "maske", + "tooltip": "Atlanırsa, düzenleme tüm görüntüye uygulanır." + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "denetleme", + "tooltip": "Denetleme ayarları" + }, + "moderation_prompt_content_moderation": { + "name": "istem_içeriği_denetleme" + }, + "moderation_visual_input_moderation": { + "name": "görsel_girdi_denetleme" + }, + "moderation_visual_output_moderation": { + "name": "görsel_çıktı_denetleme" + }, + "negative_prompt": { + "name": "negatif_istem" + }, + "prompt": { + "name": "istem", + "tooltip": "Görüntüyü düzenlemek için talimat" + }, + "seed": { + "name": "tohum" + }, + "steps": { + "name": "adımlar" + }, + "structured_prompt": { + "name": "yapılandırılmış_istem", + "tooltip": "JSON formatında yapılandırılmış düzenleme istemi içeren bir dize. Hassas ve programatik kontrol için normal istem yerine bunu kullanın." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "yapılandırılmış_istem", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "İlk ve son kareleri kullanarak video oluşturun.", "display_name": "ByteDance İlk-Son-Kare'den Videoya", @@ -351,6 +413,10 @@ "name": "ilk_kare", "tooltip": "Video için kullanılacak ilk kare." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "last_frame": { "name": "son_kare", "tooltip": "Video için kullanılacak son kare." @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "İstek üzerine api aracılığıyla ByteDance modellerini kullanarak görüntüleri düzenleyin", - "display_name": "ByteDance Görüntü Düzenleme", - "inputs": { - "control_after_generate": { - "name": "oluşturma sonrası kontrol" - }, - "guidance_scale": { - "name": "rehberlik_ölçeği", - "tooltip": "Daha yüksek değer, görüntünün isteği daha yakından takip etmesini sağlar" - }, - "image": { - "name": "görüntü", - "tooltip": "Düzenlenecek temel görüntü" - }, - "model": { - "name": "model" - }, - "prompt": { - "name": "istek", - "tooltip": "Görüntüyü düzenleme talimatı" - }, - "seed": { - "name": "tohum", - "tooltip": "Oluşturma için kullanılacak tohum değeri" - }, - "watermark": { - "name": "filigran", - "tooltip": "Görüntüye \"Yapay zeka tarafından oluşturulmuştur\" filigranı eklenip eklenmeyeceği" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "İstek üzerine api aracılığıyla ByteDance modellerini kullanarak görüntüler oluşturun", "display_name": "ByteDance Görüntü", @@ -527,6 +556,10 @@ "name": "süre", "tooltip": "Oluşturulan videonun saniye cinsinden süresi." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "image": { "name": "görüntü", "tooltip": "Video için kullanılacak ilk kare." @@ -634,6 +667,10 @@ "name": "süre", "tooltip": "Çıktı videosunun saniye cinsinden süresi." }, + "generate_audio": { + "name": "ses_oluştur", + "tooltip": "Bu parametre, seedance-1-5-pro modeli dışında tüm modeller için yok sayılır." + }, "model": { "name": "model" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "seçim" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "INDEX", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "ControlNet Modelini Yükle (fark)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "3D Yükle", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "yükseklik" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "model_dosyası" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "genişlik" } @@ -6270,13 +6306,11 @@ "description": "Çıktı klasöründen bir görüntü yükleyin. Yenile düğmesine tıklandığında, düğüm görüntü listesini güncelleyecek ve otomatik olarak ilk görüntüyü seçecek, bu da kolay yinelemeye olanak tanıyacaktır.", "display_name": "Görüntü Yükle (Çıktılardan)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "görüntü" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "yüklenecek dosyayı seçin" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "LoRA'yı bypass modunda uygula. Normal LoRA'dan farklı olarak, model ağırlıklarını değiştirmez - bunun yerine LoRA hesaplamasını ileri geçiş sırasında enjekte eder. Eğitim senaryoları için kullanışlıdır.", + "display_name": "LoRA Yükle (Bypass) (Hata Ayıklama İçin)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "LoRA'nın uygulanacağı CLIP modeli." + }, + "lora_name": { + "name": "lora_name", + "tooltip": "LoRA'nın adı." + }, + "model": { + "name": "model", + "tooltip": "LoRA'nın uygulanacağı difüzyon modeli." + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "CLIP modelini ne kadar güçlü değiştireceği. Bu değer negatif olabilir." + }, + "strength_model": { + "name": "strength_model", + "tooltip": "Difüzyon modelini ne kadar güçlü değiştireceği. Bu değer negatif olabilir." + } + }, + "outputs": { + "0": { + "tooltip": "Değiştirilmiş difüzyon modeli." + }, + "1": { + "tooltip": "Değiştirilmiş CLIP modeli." + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "LoRA'yı bypass modunda uygula. Normal LoRA'dan farklı olarak, model ağırlıklarını değiştirmez - bunun yerine LoRA hesaplamasını ileri geçiş sırasında enjekte eder. Eğitim senaryoları için kullanışlıdır.", + "display_name": "LoRA Yükle (Bypass, Sadece Model) (Hata Ayıklama İçin)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "Değiştirilmiş difüzyon modeli." + } + } + }, "LoraLoaderModelOnly": { "description": "LoRA'lar, difüzyon ve CLIP modellerini değiştirmek, gizli değişkenlerin gürültüsünün giderilme şeklini değiştirmek (örneğin stiller uygulamak) için kullanılır. Birden fazla LoRA düğümü birbirine bağlanabilir.", "display_name": "SadeceModelLoRA Yükleyici", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "Bir görseli ışık ayarlarıyla ve isteğe bağlı referans tabanlı ışık transferiyle yeniden ışıklandır.", + "display_name": "Magnific Görsel Yeniden Işıklandırma", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "Gelişmiş ışık kontrolü için ince ayar seçenekleri." + }, + "change_background": { + "name": "change_background", + "tooltip": "Arka planı prompt/referansa göre değiştirir." + }, + "image": { + "name": "image", + "tooltip": "Yeniden ışıklandırılacak görsel." + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "Oluşturulan görselin orijinale daha yakın olmasını sınırlar." + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "Işık transferi uygulama yoğunluğu." + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "Orijinalden doku ve ince detayları korur." + }, + "prompt": { + "name": "prompt", + "tooltip": "Işıklandırma için açıklayıcı rehberlik. Vurgu notasyonu (1-1.4) desteklenir." + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Işıklandırmanın aktarılacağı isteğe bağlı referans görsel." + }, + "style": { + "name": "style", + "tooltip": "Çıktı için stil tercihi." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "Portreler için çoklu işleme modlarıyla cilt iyileştirme.", + "display_name": "Magnific Görsel Cilt İyileştirici", + "inputs": { + "image": { + "name": "image", + "tooltip": "İyileştirilecek portre görseli." + }, + "mode": { + "name": "mode", + "tooltip": "İşleme modu: Sanatsal iyileştirme için yaratıcı, orijinal görünümü korumak için sadık, hedefli optimizasyon için esnek." + }, + "sharpen": { + "name": "sharpen", + "tooltip": "Keskinleştirme yoğunluk seviyesi." + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "Akıllı gren yoğunluk seviyesi." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "Bir referans görüntüsünün stilini giriş görüntünüze aktarın.", + "display_name": "Magnific Görüntü Stil Transferi", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "İşleme motoru seçimi." + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "Devre dışı bırakıldığında, her üretimde bir miktar rastgelelik beklenir ve bu da daha çeşitli sonuçlar elde edilmesini sağlar." + }, + "flavor": { + "name": "flavor", + "tooltip": "Stil transferi türü." + }, + "image": { + "name": "image", + "tooltip": "Stil transferinin uygulanacağı görüntü." + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "Yüz iyileştirmeleri için portre modunu etkinleştir." + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "Stilinin çıkarılacağı referans görüntü." + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "Orijinal görüntünün yapısını korur." + }, + "style_strength": { + "name": "style_strength", + "tooltip": "Stil gücü yüzdesi." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "Rehberliği, negatif istem arasındaki farktan ziyade pozitif istemin 'yönüne' daha fazla ölçeklenecek şekilde değiştirin.", "display_name": "Mahiro o kadar sevimli ki daha iyi bir rehberlik fonksiyonunu hak ediyor!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "Herhangi Bir Şeyi Önizle", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "kaynak" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "Bir görüntüyü veya maskeyi çeşitli ölçeklendirme yöntemleriyle yeniden boyutlandırın.", "display_name": "Görsel/Maske Yeniden Boyutlandır", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "Yeniden boyutlandırma yöntemini seçin: tam boyutlar, ölçek faktörü, başka bir görüntüyle eşleştirme vb." }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "kırp" + }, + "resize_type_height": { + "name": "yükseklik" + }, + "resize_type_width": { + "name": "genişlik" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "Enterpolasyon algoritması. 'area' küçültme için en iyisidir, 'lanczos' büyütme için uygundur, 'nearest-exact' piksel sanatı için idealdir." } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D: Görsel(ler)den Modele (Pro)", + "inputs": { + "control_after_generate": { + "name": "oluşturma sonrası kontrol" + }, + "face_count": { + "name": "yüz sayısı" + }, + "generate_type": { + "name": "oluşturma türü" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "image": { + "name": "görsel" + }, + "image_back": { + "name": "arka görsel" + }, + "image_left": { + "name": "sol görsel" + }, + "image_right": { + "name": "sağ görsel" + }, + "model": { + "name": "model", + "tooltip": "`3.1` modeli için LowPoly seçeneği kullanılamaz." + }, + "seed": { + "name": "tohum", + "tooltip": "Tohum, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; sonuçlar tohumdan bağımsız olarak deterministik değildir." + } + }, + "outputs": { + "0": { + "name": "model_dosyası", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D: Metinden Modele (Pro)", + "inputs": { + "control_after_generate": { + "name": "oluşturma sonrası kontrol" + }, + "face_count": { + "name": "yüz sayısı" + }, + "generate_type": { + "name": "oluşturma türü" + }, + "generate_type_pbr": { + "name": "pbr" + }, + "model": { + "name": "model", + "tooltip": "`3.1` modeli için LowPoly seçeneği kullanılamaz." + }, + "prompt": { + "name": "istem", + "tooltip": "En fazla 1024 karakter desteklenir." + }, + "seed": { + "name": "tohum", + "tooltip": "Tohum, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; sonuçlar tohumdan bağımsız olarak deterministik değildir." + } + }, + "outputs": { + "0": { + "name": "model_dosyası", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "görüntüleri_otomatik_yeniden_boyutlandır" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "görüntü1" + }, + "image2": { + "name": "görüntü2" + }, + "image3": { + "name": "görüntü3" + }, + "image_encoder": { + "name": "görüntü_kodlayıcı" + }, + "prompt": { + "name": "istem" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "Metni Küçük Harfe Çevir", "inputs": { @@ -13643,6 +13970,10 @@ "name": "bucket_mode", "tooltip": "Çözünürlük kova modunu etkinleştir. Etkinleştirildiğinde, ResolutionBucket düğümünden önceden kovalanmış latentler beklenir." }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "Eğitim için bypass modunu etkinleştir. Etkinleştirildiğinde, adapter'lar ağırlık değişikliği yerine forward hook'lar ile uygulanır. Ağırlıkların doğrudan değiştirilemediği quantized modeller için kullanışlıdır." + }, "control_after_generate": { "name": "oluşturduktan sonra kontrol et" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "audio_encoder_output_1" + }, + "audio_scale": { + "name": "audio_scale" + }, + "clip_vision_output": { + "name": "clip_vision_output" + }, + "height": { + "name": "yükseklik" + }, + "length": { + "name": "uzunluk" + }, + "mode": { + "name": "mod" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "motion_frame_count": { + "name": "hareket_kare_sayısı", + "tooltip": "Hareket bağlamı olarak kullanılacak önceki karelerin sayısı." + }, + "negative": { + "name": "negatif" + }, + "positive": { + "name": "pozitif" + }, + "previous_frames": { + "name": "önceki_kareler" + }, + "start_image": { + "name": "başlangıç_görseli" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "genişlik" + } + }, + "outputs": { + "0": { + "name": "model", + "tooltip": null + }, + "1": { + "name": "pozitif", + "tooltip": null + }, + "2": { + "name": "negatif", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "kırpılmış_görsel", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "Düşük çözünürlüklü veya bulanık videolar için hızlı, yüksek kaliteli video yükseltici; çözünürlüğü artırır ve netliği geri kazandırır.", + "display_name": "FlashVSR Video Yükseltme", + "inputs": { + "target_resolution": { + "name": "hedef_çözünürlük" + }, + "video": { + "name": "video" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "Görüntü çözünürlüğünü ve kalitesini artırın, fotoğrafları keskin ve ayrıntılı sonuçlar için 4K veya 8K'ya yükseltin.", + "display_name": "WaveSpeed Görüntü Yükseltme", + "inputs": { + "image": { + "name": "görüntü" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "hedef_çözünürlük" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "Webcam Yakalama", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "görüntü" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "genişlik" } diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 6cf098949..756dc8d51 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "Seçim araç kutusunu göster" + "name": "Seçim araç kutusunu göster", + "tooltip": "Düğümler seçildiğinde, yaygın işlemlere hızlı erişim sağlayan bir araç çubuğu gösterir." }, "Comfy_ConfirmClear": { "name": "İş akışını temizlerken onay iste" @@ -147,7 +148,8 @@ "Linear": "Doğrusal", "Spline": "Eğri", "Straight": "Düz" - } + }, + "tooltip": "Tuvaldeki düğümler arasındaki bağlantı çizgilerinin görünümünü ve görünürlüğünü kontrol eder." }, "Comfy_Load3D_3DViewerEnable": { "name": "3D Görüntüleyiciyi Etkinleştir (Beta)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "Görüntü önizlemesinin altında genişlik × yüksekliği göster" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "Tüm düğümlerde gelişmiş araçları her zaman göster", + "tooltip": "Etkinleştirildiğinde, gelişmiş araçlar tüm düğümlerde ayrı ayrı genişletmeye gerek kalmadan her zaman görünür olur." + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "Bağlantıyı otomatik olarak düğüm yuvasına yapıştır", "tooltip": "Bir bağlantıyı bir düğümün üzerine sürüklerken, bağlantı otomatik olarak düğüm üzerindeki uygun bir giriş yuvasına yapışır" @@ -332,6 +338,10 @@ "name": "Kuyruk geçmişi boyutu", "tooltip": "Kuyruk geçmişinde gösterilen maksimum görev sayısı." }, + "Comfy_Queue_QPOV2": { + "name": "Varlıklar yan panelinde birleşik iş kuyruğunu kullan", + "tooltip": "Kayan iş kuyruğu panelini, Varlıklar yan paneline gömülü eşdeğer bir iş kuyruğu ile değiştirir. Kayan panel düzenine dönmek için bunu devre dışı bırakabilirsiniz." + }, "Comfy_Sidebar_Location": { "name": "Kenar çubuğu konumu", "options": { @@ -466,6 +476,7 @@ "tooltip": "Yeniden yönlendirme merkez noktasından bezier kontrol noktası ofseti" }, "pysssss_SnapToGrid": { - "name": "Her zaman ızgaraya yapıştır" + "name": "Her zaman ızgaraya yapıştır", + "tooltip": "Etkinleştirildiğinde, düğümler taşındığında veya yeniden boyutlandırıldığında otomatik olarak ızgaraya hizalanır." } } diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 0a148681a..97c21a1f9 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -24,6 +24,7 @@ "assets": "資產", "baseModels": "基礎模型", "browseAssets": "瀏覽資產", + "byType": "依類型", "checkpoints": "Checkpoints", "civitaiLinkExample": "{example} {link}", "civitaiLinkExampleStrong": "範例:", @@ -45,6 +46,10 @@ "failed": "下載失敗", "inProgress": "正在下載 {assetName}..." }, + "emptyImported": { + "canImport": "尚未匯入模型。點擊「匯入模型」以新增您的模型。", + "restricted": "個人模型僅限 Creator 方案及以上等級使用。" + }, "errorFileTooLarge": "檔案超過允許的最大大小限制", "errorFormatNotAllowed": "僅允許 SafeTensor 格式", "errorModelTypeNotSupported": "不支援此模型類型", @@ -61,6 +66,7 @@ "finish": "完成", "genericLinkPlaceholder": "請在此貼上連結", "importAnother": "匯入其他", + "imported": "已匯入", "jobId": "工作 ID", "loadingModels": "正在載入 {type}...", "maxFileSize": "最大檔案大小:{size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3D 模型" }, "modelAssociatedWithLink": "您提供的連結所對應的模型:", + "modelInfo": { + "addBaseModel": "新增基礎模型...", + "addTag": "新增標籤...", + "additionalTags": "其他標籤", + "baseModelUnknown": "基礎模型未知", + "basicInfo": "基本資訊", + "compatibleBaseModels": "相容基礎模型", + "description": "描述", + "descriptionNotSet": "尚未設定描述", + "descriptionPlaceholder": "為此模型新增描述...", + "displayName": "顯示名稱", + "editDisplayName": "編輯顯示名稱", + "fileName": "檔案名稱", + "modelDescription": "模型描述", + "modelTagging": "模型標籤", + "modelType": "模型類型", + "noAdditionalTags": "沒有其他標籤", + "selectModelPrompt": "選擇模型以查看其資訊", + "selectModelType": "選擇模型類型...", + "source": "來源", + "title": "模型資訊", + "triggerPhrases": "觸發詞", + "viewOnSource": "在 {source} 上檢視" + }, "modelName": "模型名稱", "modelNamePlaceholder": "請輸入此模型的名稱", "modelTypeSelectorLabel": "這是什麼類型的模型?", @@ -238,6 +268,12 @@ "title": "建立帳戶" } }, + "boundingBox": { + "height": "高度", + "width": "寬度", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "清除工作流程", "deleteBlueprint": "刪除藍圖", @@ -678,6 +714,7 @@ "clearAll": "全部清除", "clearFilters": "清除篩選", "close": "關閉", + "closeDialog": "關閉對話框", "color": "顏色", "comfy": "Comfy", "comfyOrgLogoAlt": "ComfyOrg 標誌", @@ -694,6 +731,7 @@ "control_before_generate": "生成前控制", "copied": "已複製", "copy": "複製", + "copyAll": "全部複製", "copyJobId": "複製工作 ID", "copyToClipboard": "複製到剪貼簿", "copyURL": "複製網址", @@ -756,6 +794,8 @@ "goToNode": "前往節點", "graphNavigation": "圖形導覽", "halfSpeed": "0.5倍速", + "hideLeftPanel": "隱藏左側面板", + "hideRightPanel": "隱藏右側面板", "icon": "圖示", "imageFailedToLoad": "無法載入圖片", "imagePreview": "圖片預覽 - 使用方向鍵在圖片間導航", @@ -797,6 +837,7 @@ "name": "名稱", "newFolder": "新資料夾", "next": "下一步", + "nightly": "NIGHTLY", "no": "否", "noAudioRecorded": "沒有錄製到音訊", "noItems": "沒有項目", @@ -811,6 +852,7 @@ "nodeSlotsError": "節點插槽錯誤", "nodeWidgetsError": "節點小工具錯誤", "nodes": "節點", + "nodesCount": "{count} 個節點 | {count} 個節點 | {count} 個節點", "nodesRunning": "節點執行中", "none": "無", "nothingToCopy": "沒有可複製的項目", @@ -885,7 +927,9 @@ "selectedFile": "已選取的檔案", "setAsBackground": "設為背景", "settings": "設定", + "showLeftPanel": "顯示左側面板", "showReport": "顯示報告", + "showRightPanel": "顯示右側面板", "singleSelectDropdown": "單選下拉式選單", "sort": "排序", "source": "來源", @@ -908,6 +952,7 @@ "updating": "更新中", "upload": "上傳", "usageHint": "使用提示", + "use": "使用", "user": "使用者", "versionMismatchWarning": "版本相容性警告", "versionMismatchWarningMessage": "{warning}:{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。", @@ -915,11 +960,10 @@ "videoPreview": "影片預覽 - 使用方向鍵在影片間導航", "viewImageOfTotal": "檢視第 {index} 張圖片(共 {total} 張)", "viewVideoOfTotal": "檢視第 {index} 個影片(共 {total} 個)", - "vitePreloadErrorMessage": "應用程式的新版本已發佈。您要重新載入嗎?\n如果不重新載入,應用程式的某些部分可能無法正常運作。\n您可以拒絕並先儲存進度,稍後再重新載入。", - "vitePreloadErrorTitle": "新版本可用", "volume": "音量", "warning": "警告", - "workflow": "工作流程" + "workflow": "工作流程", + "you": "你" }, "graphCanvasMenu": { "fitView": "適合視窗", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "沒有可比較的圖像" }, + "imageCrop": { + "cropPreviewAlt": "裁切預覽", + "loading": "載入中...", + "noInputImage": "未連接輸入影像" + }, "importFailed": { "copyError": "複製錯誤", "title": "匯入失敗" @@ -1606,17 +1655,25 @@ "title": "此工作流程有缺少的節點" } }, + "nightly": { + "badge": { + "label": "預覽版本", + "tooltip": "您正在使用 ComfyUI 的夜間版本。請使用反饋按鈕分享您對這些功能的看法。" + } + }, "nodeCategories": { "": "", "3d": "3D", "3d_models": "3D 模型", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "字節跳動", "Gemini": "雙子星", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "月谷馬雷", @@ -1627,11 +1684,13 @@ "Runway": "跑道", "Sora": "蒼穹", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "三重奏", "Veo": "Veo", "Vidu": "維度", "Wan": "Wan", + "WaveSpeed": "WaveSpeed", "_for_testing": "_for_testing", "advanced": "進階", "animation": "動畫", @@ -1841,6 +1900,7 @@ }, "groupSettings": "群組設定", "groups": "群組", + "hideAdvancedInputsButton": "隱藏進階輸入", "hideInput": "隱藏輸入", "info": "資訊", "inputs": "輸入", @@ -2073,6 +2133,7 @@ "NodeLibrary": "節點庫", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "通知偏好設定", + "Other": "其他", "PLY": "PLY", "PlanCredits": "方案與點數", "Pointer": "指標", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Vue 節點", "VueNodes": "Vue 節點", "Window": "視窗", - "Workflow": "工作流程" + "Workflow": "工作流程", + "Workspace": "工作區" }, "shape": { "CARD": "卡片", @@ -2118,12 +2180,14 @@ "viewControls": "檢視控制" }, "sideToolbar": { + "activeJobStatus": "進行中作業:{status}", "assets": "資源", "backToAssets": "返回所有資源", "browseTemplates": "瀏覽範例模板", "downloads": "下載", "generatedAssetsHeader": "已產生資產", "helpCenter": "說明中心", + "importedAssetsHeader": "已匯入資產", "labels": { "assets": "資源", "console": "控制台", @@ -2168,6 +2232,7 @@ "queue": "佇列", "queueProgressOverlay": { "activeJobs": "{count} 個執行中作業", + "activeJobsShort": "{count} 個進行中", "activeJobsSuffix": "執行中作業", "cancelJobTooltip": "取消作業", "clearHistory": "清除作業佇列歷史", @@ -2256,9 +2321,15 @@ "beta": "測試版", "billedMonthly": "每月收費", "billedYearly": "每年收費 {total}", + "billingComingSoon": { + "message": "團隊計費功能即將推出。屆時你可以為你的工作區訂閱方案,並依照每位成員計價。請持續關注最新消息。", + "title": "即將推出" + }, + "cancelSubscription": "取消訂閱", "changeTo": "切換至 {plan}", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud 標誌", + "contactOwnerToSubscribe": "請聯絡工作區擁有者以訂閱", "contactUs": "聯絡我們", "creditsRemainingThisMonth": "本月剩餘點數", "creditsRemainingThisYear": "本年剩餘點數", @@ -2271,6 +2342,7 @@ "haveQuestions": "有疑問或想了解企業方案?", "invoiceHistory": "發票記錄", "learnMore": "了解更多", + "managePayment": "管理付款", "managePlan": "管理方案", "manageSubscription": "管理訂閱", "maxDuration": { @@ -2306,6 +2378,7 @@ "subscribeToComfyCloud": "訂閱 Comfy Cloud", "subscribeToRun": "訂閱", "subscribeToRunFull": "訂閱運行方案", + "subscriptionRequiredMessage": "會員需訂閱才能在雲端執行工作流程", "tierNameYearly": "{name} 年度方案", "tiers": { "creator": { @@ -2337,6 +2410,7 @@ "viewMoreDetails": "查看更多詳情", "viewMoreDetailsPlans": "查看更多方案與價格細節", "viewUsageHistory": "檢視使用記錄", + "workspaceNotSubscribed": "此工作區尚未訂閱", "yearly": "每年", "yearlyCreditsLabel": "年度總點數", "yearlyDiscount": "八折優惠", @@ -2438,6 +2512,7 @@ "failedToLoadModel": "無法載入 3D 模型", "failedToPurchaseCredits": "購買點數失敗:{error}", "failedToQueue": "加入佇列失敗", + "failedToSaveDraft": "無法儲存工作流程草稿", "failedToToggleCamera": "切換相機失敗", "failedToToggleGrid": "切換格線失敗", "failedToUpdateBackgroundColor": "更新背景顏色失敗", @@ -2486,7 +2561,8 @@ "notSet": "未設定", "provider": "登入提供者", "title": "使用者設定", - "updatePassword": "更新密碼" + "updatePassword": "更新密碼", + "workspaceSettings": "工作區設定" }, "validation": { "descriptionRequired": "說明為必填項目", @@ -2577,6 +2653,9 @@ "saveWorkflow": "儲存工作流程" }, "workspace": { + "addedToWorkspace": "你已被加入 {workspaceName}", + "inviteAccepted": "已接受邀請", + "inviteFailed": "接受邀請失敗", "unsavedChanges": { "message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?", "title": "未儲存的變更" @@ -2591,6 +2670,128 @@ "workspaceNotFound": "找不到工作區" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "建立", + "message": "工作區可讓成員共用點數池。建立後您將成為擁有者。", + "nameLabel": "工作區名稱*", + "namePlaceholder": "輸入工作區名稱", + "title": "建立新工作區" + }, + "dashboard": { + "placeholder": "儀表板工作區設定" + }, + "deleteDialog": { + "message": "任何未使用的點數或未儲存的資產都將遺失。此操作無法復原。", + "messageWithName": "刪除「{name}」?任何未使用的點數或未儲存的資產都將遺失。此操作無法復原。", + "title": "確定要刪除此工作區?" + }, + "editWorkspaceDialog": { + "nameLabel": "工作區名稱", + "save": "儲存", + "title": "編輯工作區詳細資料" + }, + "invite": "邀請", + "inviteLimitReached": "你已達到最多 50 位成員的上限", + "inviteMember": "邀請成員", + "inviteMemberDialog": { + "createLink": "建立連結", + "linkCopied": "已複製", + "linkCopyFailed": "複製連結失敗", + "linkStep": { + "copyLink": "複製連結", + "done": "完成", + "message": "請確認他們的帳號使用此電子郵件。", + "title": "將此連結發送給對方" + }, + "message": "建立可分享的邀請連結並發送給對方", + "placeholder": "輸入對方的電子郵件", + "title": "邀請他人加入此工作區" + }, + "leaveDialog": { + "leave": "離開", + "message": "除非聯絡工作區擁有者,否則您將無法再次加入。", + "title": "確定要離開此工作區?" + }, + "members": { + "actions": { + "copyLink": "複製邀請連結", + "removeMember": "移除成員", + "revokeInvite": "撤銷邀請" + }, + "columns": { + "expiryDate": "到期日", + "inviteDate": "邀請日期", + "joinDate": "加入日期" + }, + "createNewWorkspace": "建立新工作區。", + "membersCount": "{count}/50 位成員", + "noInvites": "沒有待處理邀請", + "noMembers": "沒有成員", + "pendingInvitesCount": "{count} 個待處理邀請", + "personalWorkspaceMessage": "你目前無法邀請其他成員加入你的個人工作區。若要新增成員,請", + "tabs": { + "active": "已啟用", + "pendingCount": "待處理 ({count})" + } + }, + "menu": { + "deleteWorkspace": "刪除工作區", + "deleteWorkspaceDisabledTooltip": "請先取消工作區的有效訂閱", + "editWorkspace": "編輯工作區詳細資料", + "leaveWorkspace": "離開工作區" + }, + "removeMemberDialog": { + "error": "移除成員失敗", + "message": "此成員將會從你的工作區中移除。他們已使用的點數不會退還。", + "remove": "移除成員", + "success": "已移除成員", + "title": "要移除此成員嗎?" + }, + "revokeInviteDialog": { + "message": "此人將無法再加入你的工作區,他們的邀請連結也會失效。", + "revoke": "取消邀請", + "title": "要取消邀請此人嗎?" + }, + "tabs": { + "dashboard": "儀表板", + "membersCount": "成員 ({count})", + "planCredits": "方案與點數" + }, + "toast": { + "failedToCreateWorkspace": "建立工作區失敗", + "failedToDeleteWorkspace": "刪除工作區失敗", + "failedToFetchWorkspaces": "載入工作區失敗", + "failedToLeaveWorkspace": "離開工作區失敗", + "failedToUpdateWorkspace": "更新工作區失敗", + "workspaceCreated": { + "message": "訂閱方案、邀請團隊成員並開始協作。", + "subscribe": "訂閱", + "title": "已建立工作區" + }, + "workspaceDeleted": { + "message": "該工作區已被永久刪除。", + "title": "已刪除工作區" + }, + "workspaceLeft": { + "message": "你已離開該工作區。", + "title": "已離開工作區" + }, + "workspaceUpdated": { + "message": "工作區詳細資料已儲存。", + "title": "工作區已更新" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "建立新工作區", + "maxWorkspacesReached": "您最多只能擁有 10 個工作區。請刪除一個以建立新工作區。", + "personal": "個人", + "roleMember": "成員", + "roleOwner": "擁有者", + "subscribe": "訂閱", + "switchWorkspace": "切換工作區" + }, "zoomControls": { "hideMinimap": "隱藏小地圖", "label": "縮放控制", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index e281a7029..d3b7c7048 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "使用 Bria 最新模型編輯圖像", + "display_name": "Bria 圖像編輯", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "guidance_scale": { + "name": "指引強度", + "tooltip": "數值越高,圖像越貼合提示詞。" + }, + "image": { + "name": "圖像" + }, + "mask": { + "name": "遮罩", + "tooltip": "若未設定,編輯將套用至整張圖像。" + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "審核", + "tooltip": "審核設定" + }, + "moderation_prompt_content_moderation": { + "name": "提示詞內容審核" + }, + "moderation_visual_input_moderation": { + "name": "輸入圖像審核" + }, + "moderation_visual_output_moderation": { + "name": "輸出圖像審核" + }, + "negative_prompt": { + "name": "負面提示詞" + }, + "prompt": { + "name": "提示詞", + "tooltip": "編輯圖像的指令" + }, + "seed": { + "name": "種子" + }, + "steps": { + "name": "步數" + }, + "structured_prompt": { + "name": "結構化提示詞", + "tooltip": "包含結構化編輯提示的 JSON 格式字串。若需精確、程式化控制,請使用此項取代一般提示詞。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "結構化提示詞", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "使用提示詞和首尾幀生成影片。", "display_name": "字節跳動首尾幀轉影片", @@ -351,6 +413,10 @@ "name": "首幀", "tooltip": "用於影片的首幀。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "last_frame": { "name": "尾幀", "tooltip": "用於影片的尾幀。" @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "透過 API 使用字節跳動模型根據提示詞編輯圖片", - "display_name": "字節跳動圖片編輯", - "inputs": { - "control_after_generate": { - "name": "control after generate" - }, - "guidance_scale": { - "name": "guidance_scale", - "tooltip": "數值越高,圖像越遵循提示詞" - }, - "image": { - "name": "圖片", - "tooltip": "要編輯的基礎圖片" - }, - "model": { - "name": "模型" - }, - "prompt": { - "name": "提示詞", - "tooltip": "編輯圖片的指令" - }, - "seed": { - "name": "seed", - "tooltip": "用於生成的種子值" - }, - "watermark": { - "name": "watermark", - "tooltip": "是否在圖像上添加「AI 生成」浮水印" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "透過 API 基於提示詞使用字節跳動模型生成圖像", "display_name": "字節跳動圖像", @@ -527,6 +556,10 @@ "name": "duration", "tooltip": "輸出影片的持續時間(以秒為單位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "image": { "name": "image", "tooltip": "用於影片的第一幀圖片。" @@ -634,6 +667,10 @@ "name": "持續時間", "tooltip": "輸出影片的持續時間(秒)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此參數僅適用於 seedance-1-5-pro,其他模型將會忽略。" + }, "model": { "name": "模型" }, @@ -2019,14 +2056,16 @@ "choice": { "name": "選項" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "INDEX", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "載入 ControlNet 模型(diff)", @@ -6167,8 +6206,7 @@ "Load3D": { "display_name": "載入 3D", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "高度" }, @@ -6178,10 +6216,8 @@ "model_file": { "name": "模型檔案" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "寬度" } @@ -6270,13 +6306,11 @@ "description": "從輸出資料夾載入圖片。當點擊重新整理按鈕時,節點會更新圖片清單並自動選取第一張圖片,方便進行反覆操作。", "display_name": "載入圖片(來自輸出)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "影像" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "選擇要上傳的檔案" } @@ -6378,6 +6412,60 @@ } } }, + "LoraLoaderBypass": { + "description": "以繞過模式套用 LoRA。與一般 LoRA 不同,此方式不會修改模型權重,而是在前向傳遞時注入 LoRA 計算。適用於訓練場景。", + "display_name": "載入 LoRA(繞過模式)(除錯用)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "將套用 LoRA 的 CLIP 模型。" + }, + "lora_name": { + "name": "lora_name", + "tooltip": "LoRA 的名稱。" + }, + "model": { + "name": "model", + "tooltip": "將套用 LoRA 的擴散模型。" + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "調整 CLIP 模型的強度。此值可為負數。" + }, + "strength_model": { + "name": "strength_model", + "tooltip": "調整擴散模型的強度。此值可為負數。" + } + }, + "outputs": { + "0": { + "tooltip": "已修改的擴散模型。" + }, + "1": { + "tooltip": "已修改的 CLIP 模型。" + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "以繞過模式套用 LoRA。與一般 LoRA 不同,此方式不會修改模型權重,而是在前向傳遞時注入 LoRA 計算。適用於訓練場景。", + "display_name": "載入 LoRA(繞過模式,僅模型)(除錯用)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "已修改的擴散模型。" + } + } + }, "LoraLoaderModelOnly": { "description": "LoRA 用於修改 diffusion 和 CLIP 模型,改變 latent 去噪的方式,例如套用風格。多個 LoRA 節點可以串接在一起使用。", "display_name": "LoraLoaderModelOnly", @@ -6745,6 +6833,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "透過光線調整與可選的參考光線轉移,重新打光圖像。", + "display_name": "Magnific 圖像重光", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "進階光線控制的微調選項。" + }, + "change_background": { + "name": "change_background", + "tooltip": "根據提示或參考圖修改背景。" + }, + "image": { + "name": "image", + "tooltip": "要重新打光的圖像。" + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "限制生成自由度,使結果更貼近原圖。" + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "光線轉移應用的強度。" + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "保留原圖的紋理與細節。" + }, + "prompt": { + "name": "prompt", + "tooltip": "光線描述指引。支援強調標註(1-1.4)。" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "可選的參考圖像,用於轉移光線。" + }, + "style": { + "name": "style", + "tooltip": "風格化輸出偏好。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "人像美膚增強,支援多種處理模式。", + "display_name": "Magnific 圖像美膚增強", + "inputs": { + "image": { + "name": "image", + "tooltip": "要增強的人像圖像。" + }, + "mode": { + "name": "mode", + "tooltip": "處理模式:creative 為藝術增強,faithful 保留原貌,flexible 針對性優化。" + }, + "sharpen": { + "name": "sharpen", + "tooltip": "銳化強度等級。" + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "智慧顆粒強度等級。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "將參考圖像的風格轉移到您的輸入圖像上。", + "display_name": "Magnific 圖像風格轉換", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "處理引擎選擇。" + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "停用時,每次生成都會引入一定的隨機性,產生更多元的結果。" + }, + "flavor": { + "name": "flavor", + "tooltip": "風格轉換風格。" + }, + "image": { + "name": "image", + "tooltip": "要進行風格轉換的圖像。" + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "啟用人像模式以增強臉部細節。" + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "用於提取風格的參考圖像。" + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "維持原始圖像的結構。" + }, + "style_strength": { + "name": "style_strength", + "tooltip": "風格強度的百分比。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "修改引導方式,讓其更著重於正向提示詞的「方向性」而非正負提示詞之間的差異。", "display_name": "Mahiro 太可愛了,應該要有更好的引導功能!! (。・ω・。)", @@ -10361,10 +10569,8 @@ "PreviewAny": { "display_name": "預覽任意", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "來源" } @@ -11197,19 +11403,28 @@ } }, "ResizeImageMaskNode": { + "description": "使用各種縮放方法調整圖像或 mask 的大小。", "display_name": "調整影像/遮罩大小", "inputs": { "input": { "name": "input" }, "resize_type": { - "name": "resize_type" + "name": "resize_type", + "tooltip": "選擇調整大小的方式:依照精確尺寸、縮放比例、匹配其他圖像等。" }, - "resize_type_multiplier": { - "name": "multiplier" + "resize_type_crop": { + "name": "裁切" + }, + "resize_type_height": { + "name": "高度" + }, + "resize_type_width": { + "name": "寬度" }, "scale_method": { - "name": "scale_method" + "name": "scale_method", + "tooltip": "插值演算法。'area' 適合縮小,'lanczos' 適合放大,'nearest-exact' 適合像素藝術。" } }, "outputs": { @@ -13343,6 +13558,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D:圖片轉模型(專業版)", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "face_count": { + "name": "面數" + }, + "generate_type": { + "name": "生成類型" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "image": { + "name": "圖片" + }, + "image_back": { + "name": "背面圖片" + }, + "image_left": { + "name": "左側圖片" + }, + "image_right": { + "name": "右側圖片" + }, + "model": { + "name": "模型", + "tooltip": "「LowPoly」選項在 `3.1` 模型中不可用。" + }, + "seed": { + "name": "種子", + "tooltip": "種子控制節點是否重新執行;無論種子如何,結果皆為非確定性。" + } + }, + "outputs": { + "0": { + "name": "模型檔案", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D:文字轉模型(專業版)", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "face_count": { + "name": "面數" + }, + "generate_type": { + "name": "生成類型" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "model": { + "name": "模型", + "tooltip": "「LowPoly」選項在 `3.1` 模型中不可用。" + }, + "prompt": { + "name": "提示詞", + "tooltip": "最多支援 1024 個字元。" + }, + "seed": { + "name": "種子", + "tooltip": "種子控制節點是否重新執行;無論種子如何,結果皆為非確定性。" + } + }, + "outputs": { + "0": { + "name": "模型檔案", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "TextEncodeAceStepAudio", "inputs": { @@ -13438,6 +13731,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "自動調整圖像尺寸" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "圖像1" + }, + "image2": { + "name": "圖像2" + }, + "image3": { + "name": "圖像3" + }, + "image_encoder": { + "name": "圖像編碼器" + }, + "prompt": { + "name": "提示詞" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "轉換為小寫", "inputs": { @@ -13643,6 +13970,10 @@ "name": "解析度分桶模式", "tooltip": "啟用解析度分桶模式。啟用後,需從 ResolutionBucket 節點輸入預先分桶的 latent。" }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "啟用訓練旁路模式。啟用後,adapter 會透過 forward hook 應用,而不是直接修改權重。適用於無法直接修改權重的量化模型。" + }, "control_after_generate": { "name": "生成後控制" }, @@ -15609,6 +15940,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "音訊編碼器輸出 1" + }, + "audio_scale": { + "name": "音訊縮放" + }, + "clip_vision_output": { + "name": "clip 視覺輸出" + }, + "height": { + "name": "高度" + }, + "length": { + "name": "長度" + }, + "mode": { + "name": "模式" + }, + "model": { + "name": "模型" + }, + "model_patch": { + "name": "模型修補" + }, + "motion_frame_count": { + "name": "動作影格數", + "tooltip": "用作動作參考的前置影格數量。" + }, + "negative": { + "name": "負向提示" + }, + "positive": { + "name": "正向提示" + }, + "previous_frames": { + "name": "前置影格" + }, + "start_image": { + "name": "起始圖像" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "寬度" + } + }, + "outputs": { + "0": { + "name": "模型", + "tooltip": null + }, + "1": { + "name": "正向提示", + "tooltip": null + }, + "2": { + "name": "負向提示", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "裁切圖像", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMoveConcatTrack", "inputs": { @@ -16125,6 +16529,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "快速高品質影片升頻器,提升解析度並恢復低解析度或模糊影片的清晰度。", + "display_name": "FlashVSR 影片升頻", + "inputs": { + "target_resolution": { + "name": "目標解析度" + }, + "video": { + "name": "影片" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "提升圖像解析度與品質,將照片升頻至 4K 或 8K,獲得銳利細緻的效果。", + "display_name": "WaveSpeed 圖像升頻", + "inputs": { + "image": { + "name": "圖像" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "目標解析度" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "網路攝影機擷取", "inputs": { @@ -16137,8 +16578,7 @@ "image": { "name": "影像" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "寬度" } diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 060c8fe0e..73a444246 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "顯示選取工具箱" + "name": "顯示選取工具箱", + "tooltip": "當選取節點時顯示浮動工具列,快速存取常用操作。" }, "Comfy_ConfirmClear": { "name": "清除工作流程時需要確認" @@ -147,7 +148,8 @@ "Linear": "線性", "Spline": "曲線", "Straight": "直線" - } + }, + "tooltip": "控制畫布上節點連接線的外觀與可見性。" }, "Comfy_Load3D_3DViewerEnable": { "name": "啟用 3D 檢視器(測試版)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "在圖片預覽下方顯示寬度 × 高度" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "在所有節點上總是顯示進階小工具", + "tooltip": "啟用後,所有節點的進階小工具將始終可見,無需單獨展開。" + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "自動吸附連結到節點插槽", "tooltip": "拖曳連結到節點時,連結會自動吸附到節點上可用的輸入插槽" @@ -332,6 +338,10 @@ "name": "佇列歷史記錄大小", "tooltip": "佇列歷史中顯示的最大任務數量。" }, + "Comfy_Queue_QPOV2": { + "name": "在資產側邊欄中使用統一任務佇列", + "tooltip": "將浮動任務佇列面板替換為嵌入資產側邊欄的等效任務佇列。您可以停用此選項以恢復浮動面板佈局。" + }, "Comfy_Sidebar_Location": { "name": "側邊欄位置", "options": { @@ -466,6 +476,7 @@ "tooltip": "貝茲控制點相對於重導中心點的偏移量" }, "pysssss_SnapToGrid": { - "name": "總是對齊格線" + "name": "總是對齊格線", + "tooltip": "啟用後,移動或調整節點大小時會自動對齊網格。" } } diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 1108b1702..aeef80dcc 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -24,6 +24,7 @@ "assets": "资产", "baseModels": "基础模型", "browseAssets": "浏览资产", + "byType": "按类型", "checkpoints": "模型", "civitaiLinkExample": "案例: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor", "civitaiLinkExampleStrong": "案例:", @@ -45,6 +46,10 @@ "failed": "下载失败", "inProgress": "正在下载 {assetName}..." }, + "emptyImported": { + "canImport": "尚未导入模型。点击“导入模型”添加您的模型。", + "restricted": "个人模型仅限创作者及以上等级使用。" + }, "errorFileTooLarge": "允许执行文件的文件大小限制", "errorFormatNotAllowed": "仅允许 SafeTensor 格式", "errorModelTypeNotSupported": "不支持该类型的模型", @@ -61,6 +66,7 @@ "finish": "完成", "genericLinkPlaceholder": "粘贴链接到这", "importAnother": "导入其他", + "imported": "已导入", "jobId": "任务ID", "loadingModels": "正在加载{type}...", "maxFileSize": "最大文件大小:{size}", @@ -70,6 +76,30 @@ "threeDModelPlaceholder": "3D 模型" }, "modelAssociatedWithLink": "您提供的链接的模型:", + "modelInfo": { + "addBaseModel": "添加基础模型...", + "addTag": "添加标签...", + "additionalTags": "附加标签", + "baseModelUnknown": "基础模型未知", + "basicInfo": "基本信息", + "compatibleBaseModels": "兼容基础模型", + "description": "描述", + "descriptionNotSet": "未设置描述", + "descriptionPlaceholder": "为此模型添加描述...", + "displayName": "显示名称", + "editDisplayName": "编辑显示名称", + "fileName": "文件名", + "modelDescription": "模型描述", + "modelTagging": "模型标签", + "modelType": "模型类型", + "noAdditionalTags": "无附加标签", + "selectModelPrompt": "选择一个模型以查看其信息", + "selectModelType": "选择模型类型...", + "source": "来源", + "title": "模型信息", + "triggerPhrases": "触发短语", + "viewOnSource": "在 {source} 上查看" + }, "modelName": "模型名", "modelNamePlaceholder": "输入该模型的名称", "modelTypeSelectorLabel": "这是什么类型的模型?", @@ -112,7 +142,7 @@ "uploadFailed": "导入失败", "uploadModel": "导入模型", "uploadModelDescription1": "粘贴 Civitai 模型下载链接,将其添加到库中。", - "uploadModelDescription1Generic": "站贴模型下载链接,将其添加到模型库中。", + "uploadModelDescription1Generic": "粘贴模型下载链接,将其添加到模型库中。", "uploadModelDescription2": "目前仅支持 https://civitai.com 链接。", "uploadModelDescription2Generic": "仅支持来自以下提供服务的 URL :", "uploadModelDescription2Link": "https://civitai.com/models", @@ -238,6 +268,12 @@ "title": "创建一个账户" } }, + "boundingBox": { + "height": "高度", + "width": "宽度", + "x": "X", + "y": "Y" + }, "breadcrumbsMenu": { "clearWorkflow": "清除工作流", "deleteBlueprint": "删除蓝图", @@ -446,7 +482,7 @@ "Open Image": "打开图像", "Open in Mask Editor": "用遮罩编辑器打开", "Outputs": "输出", - "Paste": "站贴", + "Paste": "粘贴", "Pin": "固定", "Properties": "属性", "Properties Panel": "属性面板", @@ -678,6 +714,7 @@ "clearAll": "全部清除", "clearFilters": "清除筛选", "close": "关闭", + "closeDialog": "关闭对话框", "color": "颜色", "comfy": "舒适", "comfyOrgLogoAlt": "ComfyOrg 徽标", @@ -694,6 +731,7 @@ "control_before_generate": "生成前控制", "copied": "已复制", "copy": "复制", + "copyAll": "全部复制", "copyJobId": "复制队列 ID", "copyToClipboard": "复制到剪贴板", "copyURL": "复制链接", @@ -756,6 +794,8 @@ "goToNode": "转到节点", "graphNavigation": "图形导航", "halfSpeed": "0.5倍", + "hideLeftPanel": "隐藏左侧面板", + "hideRightPanel": "隐藏右侧面板", "icon": "图标", "imageFailedToLoad": "图像加载失败", "imagePreview": "图片预览 - 使用方向键切换图片", @@ -797,6 +837,7 @@ "name": "名称", "newFolder": "新文件夹", "next": "下一个", + "nightly": "NIGHTLY", "no": "否", "noAudioRecorded": "未录制音频", "noItems": "无项目", @@ -811,6 +852,7 @@ "nodeSlotsError": "节点插槽错误", "nodeWidgetsError": "节点控件错误", "nodes": "节点", + "nodesCount": "{count} 个节点", "nodesRunning": "节点正在运行", "none": "无", "nothingToCopy": "没有可以复制的内容", @@ -885,7 +927,9 @@ "selectedFile": "已选文件", "setAsBackground": "设为背景", "settings": "设置", + "showLeftPanel": "显示左侧面板", "showReport": "显示报告", + "showRightPanel": "显示右侧面板", "singleSelectDropdown": "单选下拉框", "sort": "排序", "source": "来源", @@ -908,6 +952,7 @@ "updating": "更新中", "upload": "上传", "usageHint": "使用提示", + "use": "使用", "user": "用户", "versionMismatchWarning": "版本兼容性警告", "versionMismatchWarningMessage": "{warning}:{detail} 请参阅 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。", @@ -915,11 +960,10 @@ "videoPreview": "视频预览 - 使用方向键切换视频", "viewImageOfTotal": "查看第 {index} 张图片,共 {total} 张", "viewVideoOfTotal": "查看第 {index} 个视频,共 {total} 个", - "vitePreloadErrorMessage": "应用已发布新版本。是否立即重新加载?\n如果不重新加载,应用的某些功能可能无法正常工作。\n您可以先拒绝,保存进度后再重新加载。", - "vitePreloadErrorTitle": "新版本可用", "volume": "音量", "warning": "警告", - "workflow": "工作流" + "workflow": "工作流", + "you": "你" }, "graphCanvasMenu": { "fitView": "适应视图", @@ -982,6 +1026,11 @@ "imageCompare": { "noImages": "没有可以对比的图像" }, + "imageCrop": { + "cropPreviewAlt": "裁剪预览", + "loading": "加载中...", + "noInputImage": "未连接输入图像" + }, "importFailed": { "copyError": "复制错误", "title": "导入失败" @@ -1606,17 +1655,25 @@ "title": "该工作流含有缺失节点" } }, + "nightly": { + "badge": { + "label": "预览版", + "tooltip": "您正在使用 ComfyUI 的夜间版本。请使用反馈按钮分享您对这些功能的看法。" + } + }, "nodeCategories": { "": "", "3d": "3d", "3d_models": "3D模型", "BFL": "BFL", + "Bria": "Bria", "ByteDance": "字节跳动", "Gemini": "Gemini", "Ideogram": "Ideogram", "Kling": "Kling", "LTXV": "LTXV", "Luma": "Luma", + "Magnific": "Magnific", "Meshy": "Meshy", "MiniMax": "MiniMax", "Moonvalley Marey": "Moonvalley Marey", @@ -1627,11 +1684,13 @@ "Runway": "Runway", "Sora": "Sora", "Stability AI": "Stability AI", + "Tencent": "Tencent", "Topaz": "Topaz", "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", "Wan": "Wan万相", + "WaveSpeed": "WaveSpeed", "_for_testing": "_用于测试", "advanced": "高级", "animation": "动画", @@ -1841,6 +1900,7 @@ }, "groupSettings": "分组设置", "groups": "分组", + "hideAdvancedInputsButton": "隐藏高级输入", "hideInput": "隐藏输入", "info": "信息", "inputs": "输入", @@ -2073,6 +2133,7 @@ "NodeLibrary": "节点库", "Nodes 2_0": "Nodes 2.0", "Notification Preferences": "通知偏好", + "Other": "其他", "PLY": "PLY", "PlanCredits": "计划与积分", "Pointer": "指针", @@ -2092,7 +2153,8 @@ "Vue Nodes": "Nodes 2.0", "VueNodes": "Nodes 2.0", "Window": "窗口", - "Workflow": "工作流" + "Workflow": "工作流", + "Workspace": "工作区" }, "shape": { "CARD": "卡片", @@ -2118,12 +2180,14 @@ "viewControls": "视图控制" }, "sideToolbar": { + "activeJobStatus": "当前任务:{status}", "assets": "资产", "backToAssets": "返回所有资产", "browseTemplates": "浏览示例模板", "downloads": "下载", "generatedAssetsHeader": "生成的资源", "helpCenter": "帮助中心", + "importedAssetsHeader": "已导入资源", "labels": { "assets": "资产", "console": "控制台", @@ -2179,6 +2243,7 @@ "queue": "队列", "queueProgressOverlay": { "activeJobs": "{count} 个活跃任务", + "activeJobsShort": "{count} 个活动任务 | {count} 个活动任务", "activeJobsSuffix": "活跃任务", "cancelJobTooltip": "取消任务", "clearHistory": "清除任务记录", @@ -2267,9 +2332,15 @@ "beta": "测试版", "billedMonthly": "每月付款", "billedYearly": "{total} 每年付款", + "billingComingSoon": { + "message": "团队计费功能即将上线。您将可以为您的工作区按成员数订阅套餐。敬请关注后续更新。", + "title": "即将推出" + }, + "cancelSubscription": "取消订阅", "changeTo": "更改为 {plan}", "comfyCloud": "Comfy 云", "comfyCloudLogo": "Comfy Cloud Logo", + "contactOwnerToSubscribe": "请联系工作区所有者进行订阅", "contactUs": "联系我们", "creditsRemainingThisMonth": "本月剩余积分", "creditsRemainingThisYear": "今年剩余积分", @@ -2282,6 +2353,7 @@ "haveQuestions": "对企业级有疑问?", "invoiceHistory": "发票历史", "learnMore": "了解更多", + "managePayment": "管理付款", "managePlan": "管理订阅", "manageSubscription": "管理订阅", "maxDuration": { @@ -2317,6 +2389,7 @@ "subscribeToComfyCloud": "订阅 Comfy Cloud", "subscribeToRun": "订阅", "subscribeToRunFull": "订阅 Run", + "subscriptionRequiredMessage": "成员在云端运行工作流需要订阅", "tierNameYearly": "{name} 年度", "tiers": { "creator": { @@ -2348,6 +2421,7 @@ "viewMoreDetails": "查看更多详情", "viewMoreDetailsPlans": "查看有关订阅和定价的更多信息", "viewUsageHistory": "查看使用历史", + "workspaceNotSubscribed": "此工作区未订阅", "yearly": "年度", "yearlyCreditsLabel": "总共年度积分", "yearlyDiscount": "20% 减免", @@ -2449,6 +2523,7 @@ "failedToLoadModel": "无法加载3D模型", "failedToPurchaseCredits": "购买积分失败:{error}", "failedToQueue": "排队失败", + "failedToSaveDraft": "保存工作流草稿失败", "failedToToggleCamera": "切换镜头失败", "failedToToggleGrid": "切换网格失败", "failedToUpdateBackgroundColor": "更新背景色失败", @@ -2497,7 +2572,8 @@ "notSet": "未设置", "provider": "登录方式", "title": "我的用户设置", - "updatePassword": "更新密码" + "updatePassword": "更新密码", + "workspaceSettings": "工作区设置" }, "validation": { "descriptionRequired": "描述是必填的", @@ -2588,6 +2664,9 @@ "saveWorkflow": "保存工作流" }, "workspace": { + "addedToWorkspace": "您已被加入 {workspaceName}", + "inviteAccepted": "邀请已接受", + "inviteFailed": "接受邀请失败", "unsavedChanges": { "message": "您有未保存的更改。是否要放弃这些更改并切换工作区?", "title": "未保存的更改" @@ -2602,6 +2681,128 @@ "workspaceNotFound": "未找到工作区" } }, + "workspacePanel": { + "createWorkspaceDialog": { + "create": "创建", + "message": "工作区让成员共享积分池。创建后您将成为所有者。", + "nameLabel": "工作区名称*", + "namePlaceholder": "请输入工作区名称", + "title": "创建新工作区" + }, + "dashboard": { + "placeholder": "仪表盘工作区设置" + }, + "deleteDialog": { + "message": "任何未使用的积分或未保存的资源都将丢失。此操作无法撤销。", + "messageWithName": "删除“{name}”?任何未使用的积分或未保存的资源都将丢失。此操作无法撤销。", + "title": "删除此工作区?" + }, + "editWorkspaceDialog": { + "nameLabel": "工作区名称", + "save": "保存", + "title": "编辑工作区详情" + }, + "invite": "邀请", + "inviteLimitReached": "您已达到最多 50 名成员的上限", + "inviteMember": "邀请成员", + "inviteMemberDialog": { + "createLink": "创建链接", + "linkCopied": "已复制", + "linkCopyFailed": "复制链接失败", + "linkStep": { + "copyLink": "复制链接", + "done": "完成", + "message": "请确保其账号使用的是该邮箱。", + "title": "将此链接发送给对方" + }, + "message": "创建一个可分享的邀请链接发送给他人", + "placeholder": "输入对方邮箱", + "title": "邀请他人加入此工作区" + }, + "leaveDialog": { + "leave": "离开", + "message": "除非联系工作区所有者,否则您将无法重新加入。", + "title": "离开此工作区?" + }, + "members": { + "actions": { + "copyLink": "复制邀请链接", + "removeMember": "移除成员", + "revokeInvite": "撤销邀请" + }, + "columns": { + "expiryDate": "过期日期", + "inviteDate": "邀请日期", + "joinDate": "加入日期" + }, + "createNewWorkspace": "创建新工作区。", + "membersCount": "{count}/50 名成员", + "noInvites": "暂无待处理邀请", + "noMembers": "暂无成员", + "pendingInvitesCount": "{count} 个待处理邀请", + "personalWorkspaceMessage": "您目前无法邀请其他成员加入您的个人工作区。如需添加成员,请", + "tabs": { + "active": "已激活", + "pendingCount": "待处理({count})" + } + }, + "menu": { + "deleteWorkspace": "删除工作区", + "deleteWorkspaceDisabledTooltip": "请先取消工作区的有效订阅", + "editWorkspace": "编辑工作区详情", + "leaveWorkspace": "离开工作区" + }, + "removeMemberDialog": { + "error": "移除成员失败", + "message": "该成员将被移出您的工作区。其已使用的积分不会被退还。", + "remove": "移除成员", + "success": "成员已移除", + "title": "移除该成员?" + }, + "revokeInviteDialog": { + "message": "该成员将无法再加入您的工作区,其邀请链接将失效。", + "revoke": "取消邀请", + "title": "取消邀请此人?" + }, + "tabs": { + "dashboard": "仪表盘", + "membersCount": "成员({count})", + "planCredits": "套餐与积分" + }, + "toast": { + "failedToCreateWorkspace": "创建工作区失败", + "failedToDeleteWorkspace": "删除工作区失败", + "failedToFetchWorkspaces": "加载工作区失败", + "failedToLeaveWorkspace": "离开工作区失败", + "failedToUpdateWorkspace": "更新工作区失败", + "workspaceCreated": { + "message": "订阅套餐,邀请队友,开始协作。", + "subscribe": "订阅", + "title": "工作区已创建" + }, + "workspaceDeleted": { + "message": "该工作区已被永久删除。", + "title": "工作区已删除" + }, + "workspaceLeft": { + "message": "您已退出该工作区。", + "title": "已退出工作区" + }, + "workspaceUpdated": { + "message": "工作区详情已保存。", + "title": "工作区已更新" + } + } + }, + "workspaceSwitcher": { + "createWorkspace": "创建新工作区", + "maxWorkspacesReached": "您最多只能拥有10个工作区。请删除一个以创建新工作区。", + "personal": "个人", + "roleMember": "成员", + "roleOwner": "所有者", + "subscribe": "订阅", + "switchWorkspace": "切换工作区" + }, "zoomControls": { "hideMinimap": "隐藏小地图", "label": "缩放控制", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 868bf4b20..9038a22a8 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -328,6 +328,68 @@ } } }, + "BriaImageEditNode": { + "description": "使用 Bria 最新模型编辑图像", + "display_name": "Bria 图像编辑", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "guidance_scale": { + "name": "guidance_scale", + "tooltip": "数值越高,图像越贴合 prompt。" + }, + "image": { + "name": "image" + }, + "mask": { + "name": "mask", + "tooltip": "如未指定,编辑将应用于整个图像。" + }, + "model": { + "name": "model" + }, + "moderation": { + "name": "moderation", + "tooltip": "内容审核设置" + }, + "moderation_prompt_content_moderation": { + "name": "prompt_content_moderation" + }, + "moderation_visual_input_moderation": { + "name": "visual_input_moderation" + }, + "moderation_visual_output_moderation": { + "name": "visual_output_moderation" + }, + "negative_prompt": { + "name": "negative_prompt" + }, + "prompt": { + "name": "prompt", + "tooltip": "编辑图像的指令" + }, + "seed": { + "name": "seed" + }, + "steps": { + "name": "steps" + }, + "structured_prompt": { + "name": "structured_prompt", + "tooltip": "包含结构化编辑提示的 JSON 字符串。使用此项可实现更精确、可编程的控制,替代常规 prompt。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "structured_prompt", + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "使用提示词和首尾帧生成视频。", "display_name": "字节跳动首尾帧转视频", @@ -351,6 +413,10 @@ "name": "第一帧", "tooltip": "用于视频的第一帧。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "last_frame": { "name": "最后一帧", "tooltip": "用于视频的最后一帧。" @@ -381,43 +447,6 @@ } } }, - "ByteDanceImageEditNode": { - "description": "通过基于提示的API使用字节跳动模型编辑图像", - "display_name": "字节跳动图片编辑", - "inputs": { - "control_after_generate": { - "name": "生成后控制" - }, - "guidance_scale": { - "name": "引导尺度", - "tooltip": "数值越高,图像越紧密地遵循提示" - }, - "image": { - "name": "图片", - "tooltip": "要编辑的基础图像" - }, - "model": { - "name": "模型" - }, - "prompt": { - "name": "提示", - "tooltip": "编辑图像的指令" - }, - "seed": { - "name": "种子", - "tooltip": "用于生成的种子" - }, - "watermark": { - "name": "水印", - "tooltip": "是否在图像上添加“AI生成”水印" - } - }, - "outputs": { - "0": { - "tooltip": null - } - } - }, "ByteDanceImageNode": { "description": "通过基于提示的API使用字节跳动模型生成图像", "display_name": "字节跳动图片", @@ -527,6 +556,10 @@ "name": "时长", "tooltip": "输出视频的时长(以秒为单位)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "image": { "name": "图片", "tooltip": "用于视频的第一帧。" @@ -634,6 +667,10 @@ "name": "时长", "tooltip": "输出视频的时长(秒)。" }, + "generate_audio": { + "name": "generate_audio", + "tooltip": "此参数仅对 seedance-1-5-pro 模型有效,其他模型将被忽略。" + }, "model": { "name": "模型" }, @@ -2021,14 +2058,16 @@ "choice": { "name": "选择" }, - "option0": { - } + "index": {}, + "option1": {} }, - "outputs": { - "0": { + "outputs": [ + null, + { + "name": "索引", "tooltip": null } - } + ] }, "DiffControlNetLoader": { "display_name": "加载ControlNet模型(diff)", @@ -2253,7 +2292,7 @@ } }, "EmptyHunyuanImageLatent": { - "display_name": "空Latent图像", + "display_name": "空Latent图像(Hunyuan)", "inputs": { "batch_size": { "name": "批次大小" @@ -6176,8 +6215,7 @@ "Load3D": { "display_name": "加载3D", "inputs": { - "clear": { - }, + "clear": {}, "height": { "name": "高度" }, @@ -6187,10 +6225,8 @@ "model_file": { "name": "模型文件" }, - "upload 3d model": { - }, - "upload extra resources": { - }, + "upload 3d model": {}, + "upload extra resources": {}, "width": { "name": "宽度" } @@ -6285,13 +6321,11 @@ "description": "从输出文件夹加载图像。当点击刷新按钮时,节点将更新图像列表并自动选择第一张图像,便于轻松迭代。", "display_name": "加载图像(来自输出)", "inputs": { - "Auto-refresh after generation": { - }, + "Auto-refresh after generation": {}, "image": { "name": "图像" }, - "refresh": { - }, + "refresh": {}, "upload": { "name": "选择文件上传" } @@ -6393,6 +6427,60 @@ } } }, + "LoraLoaderBypass": { + "description": "以旁路模式应用LoRA。与常规LoRA不同,此方式不会修改模型权重——而是在前向传播时注入LoRA计算。适用于训练场景。", + "display_name": "加载LoRA(旁路)(用于调试)", + "inputs": { + "clip": { + "name": "clip", + "tooltip": "将应用LoRA的CLIP模型。" + }, + "lora_name": { + "name": "lora_name", + "tooltip": "LoRA的名称。" + }, + "model": { + "name": "model", + "tooltip": "将应用LoRA的扩散模型。" + }, + "strength_clip": { + "name": "strength_clip", + "tooltip": "对CLIP模型的修改强度。该值可以为负数。" + }, + "strength_model": { + "name": "strength_model", + "tooltip": "对扩散模型的修改强度。该值可以为负数。" + } + }, + "outputs": { + "0": { + "tooltip": "已修改的扩散模型。" + }, + "1": { + "tooltip": "已修改的CLIP模型。" + } + } + }, + "LoraLoaderBypassModelOnly": { + "description": "以旁路模式应用LoRA。与常规LoRA不同,此方式不会修改模型权重——而是在前向传播时注入LoRA计算。适用于训练场景。", + "display_name": "加载LoRA(旁路,仅模型)(用于调试)", + "inputs": { + "lora_name": { + "name": "lora_name" + }, + "model": { + "name": "model" + }, + "strength_model": { + "name": "strength_model" + } + }, + "outputs": { + "0": { + "tooltip": "已修改的扩散模型。" + } + } + }, "LoraLoaderModelOnly": { "description": "LoRA用于修改扩散和CLIP模型,改变Latent图像的降噪方式,例如应用风格。多个LoRA节点可以链接在一起。", "display_name": "LoRA加载器(仅模型)", @@ -6761,6 +6849,126 @@ } } }, + "MagnificImageRelightNode": { + "description": "通过光照调整和可选的参考光照迁移对图像进行重光照。", + "display_name": "Magnific图像重光照", + "inputs": { + "advanced_settings": { + "name": "advanced_settings", + "tooltip": "高级光照控制的微调选项。" + }, + "change_background": { + "name": "change_background", + "tooltip": "根据提示/参考修改背景。" + }, + "image": { + "name": "image", + "tooltip": "需要重光照的图像。" + }, + "interpolate_from_original": { + "name": "interpolate_from_original", + "tooltip": "限制生成自由度,更贴近原图。" + }, + "light_transfer_strength": { + "name": "light_transfer_strength", + "tooltip": "光照迁移应用强度。" + }, + "preserve_details": { + "name": "preserve_details", + "tooltip": "保留原图的纹理和细节。" + }, + "prompt": { + "name": "prompt", + "tooltip": "光照描述性引导。支持强调符号(1-1.4)。" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "可选的参考图像,用于迁移光照。" + }, + "style": { + "name": "style", + "tooltip": "风格化输出偏好。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageSkinEnhancerNode": { + "description": "用于人像的美肤增强,支持多种处理模式。", + "display_name": "Magnific图像美肤增强", + "inputs": { + "image": { + "name": "image", + "tooltip": "需要增强的人像图像。" + }, + "mode": { + "name": "mode", + "tooltip": "处理模式:creative为艺术增强,faithful为保留原貌,flexible为定向优化。" + }, + "sharpen": { + "name": "sharpen", + "tooltip": "锐化强度等级。" + }, + "smart_grain": { + "name": "smart_grain", + "tooltip": "智能颗粒强度等级。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "MagnificImageStyleTransferNode": { + "description": "将参考图像的风格迁移到你的输入图像上。", + "display_name": "Magnific 图像风格迁移", + "inputs": { + "engine": { + "name": "engine", + "tooltip": "处理引擎选择。" + }, + "fixed_generation": { + "name": "fixed_generation", + "tooltip": "禁用时,每次生成都会引入一定的随机性,从而产生更多样化的结果。" + }, + "flavor": { + "name": "flavor", + "tooltip": "风格迁移风格类型。" + }, + "image": { + "name": "image", + "tooltip": "要进行风格迁移的图像。" + }, + "portrait_mode": { + "name": "portrait_mode", + "tooltip": "启用人像模式以增强面部效果。" + }, + "prompt": { + "name": "prompt" + }, + "reference_image": { + "name": "reference_image", + "tooltip": "用于提取风格的参考图像。" + }, + "structure_strength": { + "name": "structure_strength", + "tooltip": "保持原始图像结构。" + }, + "style_strength": { + "name": "style_strength", + "tooltip": "风格强度百分比。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Mahiro": { "description": "修改引导以更多地侧重于正面条件提示的'方向',而不是负面条件提示之间的差异。", "display_name": "真寻太可爱了,她应该有个更好的引导功能!!(。・ω・。)", @@ -10377,10 +10585,8 @@ "PreviewAny": { "display_name": "预览任意", "inputs": { - "preview": { - }, - "previewMode": { - }, + "preview": {}, + "previewMode": {}, "source": { "name": "源" } @@ -11213,19 +11419,28 @@ } }, "ResizeImageMaskNode": { + "description": "使用多种缩放方法调整图像或 mask 的大小。", "display_name": "调整图像/掩码大小", "inputs": { "input": { "name": "输入" }, "resize_type": { - "name": "调整类型" + "name": "调整类型", + "tooltip": "选择如何调整大小:按精确尺寸、缩放因子、匹配另一张图像等。" }, - "resize_type_multiplier": { - "name": "倍数" + "resize_type_crop": { + "name": "裁剪" + }, + "resize_type_height": { + "name": "高度" + }, + "resize_type_width": { + "name": "宽度" }, "scale_method": { - "name": "缩放方法" + "name": "缩放方法", + "tooltip": "插值算法。'area' 适合缩小,'lanczos' 适合放大,'nearest-exact' 适合像素艺术。" } }, "outputs": { @@ -13370,6 +13585,84 @@ } } }, + "TencentImageToModelNode": { + "display_name": "Hunyuan3D:图像转模型(专业版)", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "face_count": { + "name": "面数" + }, + "generate_type": { + "name": "生成类型" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "image": { + "name": "图像" + }, + "image_back": { + "name": "背面图像" + }, + "image_left": { + "name": "左侧图像" + }, + "image_right": { + "name": "右侧图像" + }, + "model": { + "name": "模型", + "tooltip": "LowPoly 选项在 `3.1` 模型中不可用。" + }, + "seed": { + "name": "种子", + "tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + } + }, + "outputs": { + "0": { + "name": "模型文件", + "tooltip": null + } + } + }, + "TencentTextToModelNode": { + "display_name": "Hunyuan3D:文本转模型(专业版)", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "face_count": { + "name": "面数" + }, + "generate_type": { + "name": "生成类型" + }, + "generate_type_pbr": { + "name": "PBR" + }, + "model": { + "name": "模型", + "tooltip": "LowPoly 选项在 `3.1` 模型中不可用。" + }, + "prompt": { + "name": "提示词", + "tooltip": "支持最多 1024 个字符。" + }, + "seed": { + "name": "种子", + "tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + } + }, + "outputs": { + "0": { + "name": "模型文件", + "tooltip": null + } + } + }, "TextEncodeAceStepAudio": { "display_name": "文本音频编码(AceStep)", "inputs": { @@ -13465,6 +13758,40 @@ } } }, + "TextEncodeZImageOmni": { + "display_name": "TextEncodeZImageOmni", + "inputs": { + "auto_resize_images": { + "name": "auto_resize_images" + }, + "clip": { + "name": "clip" + }, + "image1": { + "name": "image1" + }, + "image2": { + "name": "image2" + }, + "image3": { + "name": "image3" + }, + "image_encoder": { + "name": "image_encoder" + }, + "prompt": { + "name": "prompt" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "TextToLowercase": { "display_name": "文本半角", "inputs": { @@ -13670,6 +13997,10 @@ "name": "bucket模式", "tooltip": "启用分辨率 bucket 模式。开启后会先使用 分辨率Bucket 节点处理 Latent。" }, + "bypass_mode": { + "name": "bypass_mode", + "tooltip": "为训练启用旁路模式。启用后,适配器通过前向钩子应用,而不是直接修改权重。适用于权重无法直接修改的量化模型。" + }, "control_after_generate": { "name": "生成后控制" }, @@ -13742,10 +14073,6 @@ "2": { "name": "损失", "tooltip": "损失历史图" - }, - "3": { - "name": "步数", - "tooltip": "总训练步数" } } }, @@ -15640,6 +15967,79 @@ } } }, + "WanInfiniteTalkToVideo": { + "display_name": "WanInfiniteTalkToVideo", + "inputs": { + "audio_encoder_output_1": { + "name": "音频编码器输出1" + }, + "audio_scale": { + "name": "音频缩放" + }, + "clip_vision_output": { + "name": "clip视觉输出" + }, + "height": { + "name": "高度" + }, + "length": { + "name": "长度" + }, + "mode": { + "name": "模式" + }, + "model": { + "name": "模型" + }, + "model_patch": { + "name": "模型补丁" + }, + "motion_frame_count": { + "name": "运动帧数", + "tooltip": "用作运动上下文的前置帧数量。" + }, + "negative": { + "name": "负向提示" + }, + "positive": { + "name": "正向提示" + }, + "previous_frames": { + "name": "前置帧" + }, + "start_image": { + "name": "起始图像" + }, + "vae": { + "name": "vae" + }, + "width": { + "name": "宽度" + } + }, + "outputs": { + "0": { + "name": "模型", + "tooltip": null + }, + "1": { + "name": "正向提示", + "tooltip": null + }, + "2": { + "name": "负向提示", + "tooltip": null + }, + "3": { + "name": "latent", + "tooltip": null + }, + "4": { + "name": "裁剪图像", + "tooltip": null + } + } + }, "WanMoveConcatTrack": { "display_name": "WanMove合并轨道", "inputs": { @@ -16156,6 +16556,43 @@ } } }, + "WavespeedFlashVSRNode": { + "description": "快速高质量的视频超分辨率工具,可提升分辨率并恢复低分辨率或模糊视频的清晰度。", + "display_name": "FlashVSR 视频超分辨率", + "inputs": { + "target_resolution": { + "name": "target_resolution" + }, + "video": { + "name": "video" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "WavespeedImageUpscaleNode": { + "description": "提升图像分辨率和质量,将照片放大至 4K 或 8K,获得清晰细致的效果。", + "display_name": "WaveSpeed 图像超分辨率", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "target_resolution": { + "name": "target_resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WebcamCapture": { "display_name": "网络摄像头捕获", "inputs": { @@ -16168,8 +16605,7 @@ "image": { "name": "图像" }, - "waiting for camera___": { - }, + "waiting for camera___": {}, "width": { "name": "宽度" } diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index cbde4cc8c..8629ade00 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -52,7 +52,8 @@ } }, "Comfy_Canvas_SelectionToolbox": { - "name": "显示选择工具箱" + "name": "显示选择工具箱", + "tooltip": "当选择节点时显示浮动工具栏,便于快速访问常用操作。" }, "Comfy_ConfirmClear": { "name": "清除工作流时需要确认" @@ -147,7 +148,8 @@ "Linear": "直线", "Spline": "曲线", "Straight": "直角线" - } + }, + "tooltip": "控制画布上节点之间连接线的外观和可见性。" }, "Comfy_Load3D_3DViewerEnable": { "name": "启用3D查看器(测试版)", @@ -272,6 +274,10 @@ "Comfy_Node_AllowImageSizeDraw": { "name": "在图像预览下方显示宽度×高度" }, + "Comfy_Node_AlwaysShowAdvancedWidgets": { + "name": "始终在所有节点上显示高级控件", + "tooltip": "启用后,所有节点的高级控件将始终可见,无需单独展开。" + }, "Comfy_Node_AutoSnapLinkToSlot": { "name": "连线自动吸附到节点接口", "tooltip": "在节点上拖动连线时,连线会自动吸附到节点的可用输入接口。" @@ -332,6 +338,10 @@ "name": "队列历史大小", "tooltip": "队列历史中显示的最大任务数量。" }, + "Comfy_Queue_QPOV2": { + "name": "在资源侧边栏中使用统一作业队列", + "tooltip": "将浮动作业队列面板替换为嵌入资源侧边栏的等效作业队列。您可以禁用此选项以恢复为浮动面板布局。" + }, "Comfy_Sidebar_Location": { "name": "侧边栏位置", "options": { @@ -466,6 +476,7 @@ "tooltip": "贝塞尔控制点从重新路由中心点的偏移" }, "pysssss_SnapToGrid": { - "name": "始终吸附到网格" + "name": "始终吸附到网格", + "tooltip": "启用后,移动或调整节点大小时会自动对齐到网格。" } } diff --git a/src/main.ts b/src/main.ts index 94af1fcd9..ca80b87f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire' import { getFirebaseConfig } from '@/config/firebase' import '@/lib/litegraph/public/css/litegraph.css' import router from '@/router' +import { useBootstrapStore } from '@/stores/bootstrapStore' import App from './App.vue' // Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css) @@ -27,9 +28,9 @@ import { i18n } from './i18n' import { isCloud } from '@/platform/distribution/types' if (isCloud) { - const { loadRemoteConfig } = - await import('@/platform/remoteConfig/remoteConfig') - await loadRemoteConfig() + const { refreshRemoteConfig } = + await import('@/platform/remoteConfig/refreshRemoteConfig') + await refreshRemoteConfig({ useAuth: false }) } const ComfyUIPreset = definePreset(Aura, { @@ -43,6 +44,7 @@ const firebaseApp = initializeApp(getFirebaseConfig()) const app = createApp(App) const pinia = createPinia() + Sentry.init({ app, dsn: __SENTRY_DSN__, @@ -88,4 +90,7 @@ app modules: [VueFireAuth()] }) +const bootstrapStore = useBootstrapStore(pinia) +void bootstrapStore.startStoreBootstrap() + app.mount('#vue-app') diff --git a/src/platform/assets/components/ActiveMediaAssetCard.stories.ts b/src/platform/assets/components/ActiveMediaAssetCard.stories.ts new file mode 100644 index 000000000..1e32e96b2 --- /dev/null +++ b/src/platform/assets/components/ActiveMediaAssetCard.stories.ts @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { JobListItem } from '@/composables/queue/useJobList' + +import ActiveMediaAssetCard from './ActiveMediaAssetCard.vue' + +const meta: Meta = { + title: 'Platform/Assets/ActiveMediaAssetCard', + component: ActiveMediaAssetCard +} + +export default meta +type Story = StoryObj + +const SAMPLE_PREVIEW = + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg' + +function createJob(overrides: Partial = {}): JobListItem { + return { + id: 'job-1', + title: 'Running...', + meta: 'Step 5/10', + state: 'running', + progressTotalPercent: 50, + progressCurrentPercent: 75, + ...overrides + } +} + +export const Running: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + job: createJob({ + state: 'running', + progressTotalPercent: 65, + iconImageUrl: SAMPLE_PREVIEW + }) + } +} + +export const RunningWithoutPreview: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + job: createJob({ + state: 'running', + progressTotalPercent: 30 + }) + } +} + +export const Pending: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + job: createJob({ + state: 'pending', + title: 'In queue...', + progressTotalPercent: undefined + }) + } +} + +export const Initialization: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + job: createJob({ + state: 'initialization', + title: 'Initializing...', + progressTotalPercent: undefined + }) + } +} + +export const Failed: Story = { + decorators: [ + () => ({ + template: '
' + }) + ], + args: { + job: createJob({ + state: 'failed', + title: 'Failed' + }) + } +} + +export const GridLayout: Story = { + render: () => ({ + components: { ActiveMediaAssetCard }, + setup() { + const jobs: JobListItem[] = [ + createJob({ + id: 'job-1', + state: 'running', + progressTotalPercent: 75, + iconImageUrl: SAMPLE_PREVIEW + }), + createJob({ + id: 'job-2', + state: 'running', + progressTotalPercent: 45 + }), + createJob({ + id: 'job-3', + state: 'pending', + title: 'In queue...', + progressTotalPercent: undefined + }), + createJob({ + id: 'job-4', + state: 'failed', + title: 'Failed' + }) + ] + return { jobs } + }, + template: ` +
+ +
+ ` + }) +} diff --git a/src/platform/assets/components/ActiveMediaAssetCard.test.ts b/src/platform/assets/components/ActiveMediaAssetCard.test.ts new file mode 100644 index 000000000..c00d319ed --- /dev/null +++ b/src/platform/assets/components/ActiveMediaAssetCard.test.ts @@ -0,0 +1,124 @@ +import { mount } from '@vue/test-utils' +import { computed } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ActiveJobCard from './ActiveMediaAssetCard.vue' + +import type { JobListItem } from '@/composables/queue/useJobList' + +vi.mock('@/composables/queue/useJobActions', () => ({ + useJobActions: () => ({ + cancelAction: { + icon: 'icon-[lucide--x]', + label: 'Cancel', + variant: 'destructive' + }, + canCancelJob: computed(() => false), + runCancelJob: vi.fn() + }) +})) + +vi.mock('@/composables/useProgressBarBackground', () => ({ + useProgressBarBackground: () => ({ + progressBarPrimaryClass: 'bg-blue-500', + hasProgressPercent: (val: number | undefined) => typeof val === 'number', + progressPercentStyle: (val: number) => ({ width: `${val}%` }) + }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + sideToolbar: { + activeJobStatus: 'Active job: {status}' + } + } + } +}) + +const createJob = (overrides: Partial = {}): JobListItem => ({ + id: 'test-job-1', + title: 'Running...', + meta: 'Step 5/10', + state: 'running', + progressTotalPercent: 50, + progressCurrentPercent: 75, + ...overrides +}) + +const mountComponent = (job: JobListItem) => + mount(ActiveJobCard, { + props: { job }, + global: { + plugins: [i18n] + } + }) + +describe('ActiveJobCard', () => { + it('displays percentage and progress bar when job is running', () => { + const wrapper = mountComponent( + createJob({ state: 'running', progressTotalPercent: 65 }) + ) + + expect(wrapper.text()).toContain('65%') + const progressBar = wrapper.find('.bg-blue-500') + expect(progressBar.exists()).toBe(true) + expect(progressBar.attributes('style')).toContain('width: 65%') + }) + + it('displays status text when job is pending', () => { + const wrapper = mountComponent( + createJob({ + state: 'pending', + title: 'In queue...', + progressTotalPercent: undefined + }) + ) + + expect(wrapper.text()).toContain('In queue...') + const progressBar = wrapper.find('.bg-blue-500') + expect(progressBar.exists()).toBe(false) + }) + + it('shows spinner for pending state', () => { + const wrapper = mountComponent(createJob({ state: 'pending' })) + + const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]') + expect(spinner.exists()).toBe(true) + expect(spinner.classes()).toContain('animate-spin') + }) + + it('shows error icon for failed state', () => { + const wrapper = mountComponent( + createJob({ state: 'failed', title: 'Failed' }) + ) + + const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]') + expect(errorIcon.exists()).toBe(true) + expect(wrapper.text()).toContain('Failed') + }) + + it('shows preview image when running with iconImageUrl', () => { + const wrapper = mountComponent( + createJob({ + state: 'running', + iconImageUrl: 'https://example.com/preview.jpg' + }) + ) + + const img = wrapper.find('img') + expect(img.exists()).toBe(true) + expect(img.attributes('src')).toBe('https://example.com/preview.jpg') + }) + + it('has proper accessibility attributes', () => { + const wrapper = mountComponent(createJob({ title: 'Generating...' })) + + const container = wrapper.find('[role="status"]') + expect(container.exists()).toBe(true) + expect(container.attributes('aria-label')).toBe('Active job: Generating...') + }) +}) diff --git a/src/platform/assets/components/ActiveMediaAssetCard.vue b/src/platform/assets/components/ActiveMediaAssetCard.vue new file mode 100644 index 000000000..8d68b41ff --- /dev/null +++ b/src/platform/assets/components/ActiveMediaAssetCard.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/platform/assets/components/AssetBadgeGroup.vue b/src/platform/assets/components/AssetBadgeGroup.vue index 5c85713e4..46c39e917 100644 --- a/src/platform/assets/components/AssetBadgeGroup.vue +++ b/src/platform/assets/components/AssetBadgeGroup.vue @@ -1,5 +1,7 @@ diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index 11e404d41..2b1b6fbe4 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -7,32 +7,30 @@ :tabindex="interactive ? 0 : -1" :class=" cn( - 'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full', + 'select-none rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full', interactive && - 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4' + 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4', + focused && 'bg-secondary-background outline-solid' ) " + @click.stop="interactive && $emit('focus', asset)" + @focus="interactive && $emit('focus', asset)" @keydown.enter.self="interactive && $emit('select', asset)" >
- + + diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 8da39c67b..f6179f303 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -48,6 +48,10 @@ @image-loaded="handleImageLoaded" /> + + + +
() +const assetsStore = useAssetsStore() + +// Get deletion state from store +const isDeleting = computed(() => + asset ? assetsStore.isAssetDeleting(asset.id) : false +) + const emit = defineEmits<{ click: [] zoom: [asset: AssetItem] @@ -252,7 +265,7 @@ const metaInfo = computed(() => { }) const showActionsOverlay = computed(() => { - if (loading || !asset) return false + if (loading || !asset || isDeleting.value) return false return isHovered.value || selected || isVideoPlaying.value }) diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue index 57c99cf52..35b97fab7 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.vue +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -247,8 +247,8 @@ const contextMenuItems = computed(() => { icon: 'icon-[lucide--trash-2]', command: async () => { if (asset) { - const success = await actions.confirmDelete(asset) - if (success) { + const confirmed = await actions.deleteAssets(asset) + if (confirmed) { emit('asset-deleted') } } diff --git a/src/platform/assets/components/MediaAssetFilterBar.vue b/src/platform/assets/components/MediaAssetFilterBar.vue index 6a0fcdb93..6e4ec5e4c 100644 --- a/src/platform/assets/components/MediaAssetFilterBar.vue +++ b/src/platform/assets/components/MediaAssetFilterBar.vue @@ -1,40 +1,42 @@ diff --git a/src/platform/assets/components/MediaVideoTop.vue b/src/platform/assets/components/MediaVideoTop.vue index 62db746ab..4e4542182 100644 --- a/src/platform/assets/components/MediaVideoTop.vue +++ b/src/platform/assets/components/MediaVideoTop.vue @@ -7,7 +7,6 @@