Compare commits

..

8 Commits

Author SHA1 Message Date
filtered
5f94873ae3 Update pnpm lock file 2025-09-27 12:19:55 +10:00
filtered
b717a21a6a Remove unused zod-to-json-schema dependency 2025-09-27 12:19:49 +10:00
filtered
d9abeec10f Add re-exports for schemas 2025-09-27 12:14:56 +10:00
filtered
240255d9e9 Move schema files to schemas package 2025-09-27 12:14:56 +10:00
filtered
c5b5fb4021 Add tsconfig for schemas package 2025-09-27 12:14:56 +10:00
filtered
0cebc52fd2 Update pnpm lock file 2025-09-27 12:06:43 +10:00
filtered
ca243be027 Add schemas package as dependency 2025-09-27 12:02:25 +10:00
filtered
1d908e18fa Create schemas package structure 2025-09-27 11:38:26 +10:00
224 changed files with 22683 additions and 25108 deletions

2
.gitattributes vendored
View File

@@ -12,5 +12,5 @@
*.yaml text eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
src/types/comfyRegistryTypes.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -1,67 +0,0 @@
name: Setup Frontend
description: 'Setup ComfyUI frontend development environment'
inputs:
extra_server_params:
description: 'Additional parameters to pass to ComfyUI server'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v4
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Copy ComfyUI_devtools from frontend repo
shell: bash
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python requirements
shell: bash
working-directory: ComfyUI
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
pip install wait-for-it
- name: Build & Install ComfyUI_frontend
shell: bash
working-directory: ComfyUI_frontend
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Start ComfyUI server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist ${{ inputs.extra_server_params }} &
wait-for-it --service 127.0.0.1:8188 -t 600

View File

@@ -60,7 +60,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -1,4 +1,4 @@
name: Storybook and Chromatic CI
name: 'Chromatic'
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
@@ -8,100 +8,13 @@ on:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
storybook-static
tsconfig.tsbuildinfo
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
restore-keys: |
storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
storybook-cache-${{ runner.os }}-
storybook-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm build-storybook
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
retention-days: 7
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
chromatic-build-url: ${{ steps.chromatic.outputs.buildUrl }}
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
# Only run for PRs from version-bump-* branches or manual triggers
if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Chromatic baseline
@@ -116,6 +29,7 @@ jobs:
node-version: '20'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
@@ -140,92 +54,4 @@ jobs:
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
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [storybook-build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
run: |
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && startsWith(github.head_ref, 'version-bump-') && needs.chromatic-deployment.outputs.chromatic-build-url != ''
permissions:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v7
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: storybookComment.id,
body: updatedBody
});
}

View File

@@ -50,7 +50,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -15,7 +15,7 @@ jobs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Download dist artifact
uses: actions/download-artifact@v4
with:

View File

@@ -1,26 +0,0 @@
name: Devtools Python Check
on:
pull_request:
paths:
- 'tools/devtools/**'
push:
branches: [ main ]
paths:
- 'tools/devtools/**'
jobs:
syntax:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Validate Python syntax
run: python3 -m compileall -q tools/devtools

View File

@@ -22,13 +22,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: comfyanonymous/ComfyUI
path: ComfyUI
ref: master
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend
@@ -37,7 +37,7 @@ jobs:
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout custom node repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'

View File

@@ -13,8 +13,7 @@ jobs:
update-locales:
runs-on: ubuntu-latest
steps:
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend

View File

@@ -14,8 +14,7 @@ jobs:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
runs-on: ubuntu-latest
steps:
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
- name: Cache tool outputs
uses: actions/cache@v4

View File

@@ -1,15 +0,0 @@
name: Validate JSON
on:
push:
branches:
- main
pull_request:
jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.ref }}

View File

@@ -30,7 +30,7 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Get PR Number
id: pr

View File

@@ -0,0 +1,126 @@
name: PR Storybook Comment
on:
workflow_run:
workflows: ['Chromatic']
types: [requested, completed]
jobs:
comment-storybook:
runs-on: ubuntu-latest
if: >-
github.repository == 'Comfy-Org/ComfyUI_frontend'
&& github.event.workflow_run.event == 'pull_request'
&& startsWith(github.event.workflow_run.head_branch, 'version-bump-')
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Log when no PR found
if: steps.pr.outputs.result == 'null'
run: |
echo "⚠️ No open PR found for branch: ${{ github.event.workflow_run.head_branch }}"
echo "Workflow run ID: ${{ github.event.workflow_run.id }}"
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
echo "Event: ${{ github.event.workflow_run.event }}"
- name: Get workflow run details
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
id: workflow-run
uses: actions/github-script@v7
with:
script: |
const run = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
return {
conclusion: run.data.conclusion,
html_url: run.data.html_url
};
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Comment PR - Storybook Started
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body: |
<!-- STORYBOOK_BUILD_STATUS -->
## 🎨 Storybook Build Status
<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Build is starting...**
⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
- name: Comment PR - Storybook Complete
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body: |
<!-- STORYBOOK_BUILD_STATUS -->
## 🎨 Storybook Build Status
${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '✅'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && '⏭️'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && '🚫'
|| '❌'
}} **${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && 'Build completed successfully!'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'Build skipped.'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'Build cancelled.'
|| 'Build failed!'
}}**
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
### 🔗 Links
- [📊 View Workflow Run](${{ fromJSON(steps.workflow-run.outputs.result).html_url }})
---
${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '🎉 Your Storybook is ready for review!'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && ' Chromatic was skipped for this PR.'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && ' The Chromatic run was cancelled.'
|| '⚠️ Please check the workflow logs for error details.'
}}

View File

@@ -1,90 +0,0 @@
name: PR Storybook Deploy (Forks)
on:
workflow_run:
workflows: ['Storybook and Chromatic CI']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
permissions:
pull-requests: write
actions: read
steps:
- name: Log workflow trigger info
run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
- name: Get PR Number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: storybook-static
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -10,10 +10,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.label.name == 'New Browser Test Expectations'
steps:
- name: Checkout workflow repo
uses: actions/checkout@v5
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
- name: Cache Playwright browsers
uses: actions/cache@v4
with:

View File

@@ -15,14 +15,14 @@ jobs:
playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
ref: master
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
@@ -250,7 +250,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
@@ -306,7 +306,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Get start time
id: start-time
@@ -333,7 +333,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -51,7 +51,7 @@ jobs:
comfyui-manager-repo-${{ runner.os }}-
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager

View File

@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -50,7 +50,7 @@ jobs:
comfy-api-repo-${{ runner.os }}-
- name: Checkout comfy-api repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: Comfy-Org/comfy-api
path: comfy-api
@@ -68,18 +68,17 @@ jobs:
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
if [ ! -f ./src/types/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
if [ ! -s ./src/types/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
@@ -87,12 +86,12 @@ jobs:
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
pnpm lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
if [[ -z $(git status --porcelain ./src/types/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
@@ -122,4 +121,4 @@ jobs:
labels: CNR
delete-branch: true
add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts
src/types/comfyRegistryTypes.ts

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4

View File

@@ -1,2 +1,2 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts
src/types/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

View File

@@ -31,9 +31,10 @@
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
## Commit & Pull Request Guidelines
- Commits: Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes.
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
## Security & Configuration Tips
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.

View File

@@ -84,7 +84,7 @@ UI mode features:
- **Console/Network Tabs**: View logs and API calls at each step
- **Attachments Tab**: View all snapshots with expected and actual images
![Playwright UI Mode](https://github.com/user-attachments/assets/9b9cb09f-6da7-4fa0-81df-2effceced755)
![Playwright UI Mode](https://github.com/user-attachments/assets/c158c93f-b39a-44c5-a1a1-e0cc975ee9f2)
For CI or headless testing:

View File

@@ -1,221 +0,0 @@
{
"id": "e74f5af9-b886-4a21-abbf-ed535d12e2fb",
"revision": 0,
"last_node_id": 8,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadAudio",
"pos": [
41.52964782714844,
16.930862426757812
],
"size": [
444,
125
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": [
null,
null,
""
]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [
502.28570556640625,
16.857147216796875
],
"size": [
444,
525
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 3,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [
41.71427917480469,
188.0000457763672
],
"size": [
444,
553
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 5,
"type": "LoadImage",
"pos": [
958.285888671875,
16.57145118713379
],
"size": [
444,
553
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 6,
"type": "LoadImageMask",
"pos": [
503.4285888671875,
588
],
"size": [
444,
563
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageMask"
},
"widgets_values": [
null,
"alpha",
"image"
]
},
{
"id": 7,
"type": "LoadImageOutput",
"pos": [
965.1429443359375,
612
],
"size": [
444,
553
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageOutput"
},
"widgets_values": [
null,
false,
"refresh",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
},
"frontendVersion": "1.28.3"
},
"version": 0.4
}

View File

@@ -210,6 +210,28 @@ test.describe('Templates', () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test('hover effects work on template cards', async ({ comfyPage }) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card using data-testid
const firstCard = comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Check initial state - card should have transition classes
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
// Hover over the card
await firstCard.hover()
// Take snapshot after hover to verify hover effect
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
})
test('template cards descriptions adjust height dynamically', async ({
comfyPage
}) => {

View File

@@ -85,7 +85,6 @@ test.describe('Version Mismatch Warnings', () => {
test('should persist dismissed state across sessions', async ({
comfyPage
}) => {
test.setTimeout(30_000)
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({

View File

@@ -1,20 +1,67 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
} from '../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Collapse', () => {
test.describe('NodeHeader', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
test('displays node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('allows title renaming', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('handles node collapsing', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
@@ -43,7 +90,7 @@ test.describe('Vue Node Collapse', () => {
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
})
test('should show collapse/expand icon state', async ({ comfyPage }) => {
test('shows collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
@@ -63,9 +110,7 @@ test.describe('Vue Node Collapse', () => {
expect(iconClass).toContain('pi-chevron-down')
})
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

View File

@@ -1,33 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const CREATE_GROUP_HOTKEY = 'Control+g'
test.describe('Vue Node Groups', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow creating groups with hotkey', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-create-group.png'
)
})
test('should allow fitting group to contents', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)
})
})

View File

@@ -1,18 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Canvas Pan', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-paned-with-touch.png'
)
})
})

View File

@@ -1,33 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should not capture drag while zooming with ctrl+shift+drag', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBoundingBox = await checkpointNode.boundingBox()
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
// the node. The node should not capture the drag while drag-zooming.
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
{ x: 200, y: 300 },
{ x: nodeMidpointX, y: nodeMidpointY }
)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
})
})

View File

@@ -1,696 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
async function getInputLinkDetails(
page: Page,
nodeId: NodeId,
slotIndex: number
) {
return await page.evaluate(
([targetNodeId, targetSlot]) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const node = graph.getNodeById(targetNodeId)
if (!node) return null
const input = node.inputs?.[targetSlot]
if (!input) return null
const linkId = input.link
if (linkId == null) return null
const link = graph.getLink?.(linkId)
if (!link) return null
return {
id: link.id,
originId: link.origin_id,
originSlot:
typeof link.origin_slot === 'string'
? Number.parseInt(link.origin_slot, 10)
: link.origin_slot,
targetId: link.target_id,
targetSlot:
typeof link.target_slot === 'string'
? Number.parseInt(link.target_slot, 10)
: link.target_slot,
parentId: link.parentId ?? null
}
},
[nodeId, slotIndex] as const
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
}
async function expectVisibleAll(...locators: Locator[]) {
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
}
async function getSlotCenter(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const locator = slotLocator(page, nodeId, slotIndex, isInput)
await expect(locator).toBeVisible()
return await getCenter(locator)
}
async function connectSlots(
page: Page,
from: { nodeId: NodeId; index: number },
to: { nodeId: NodeId; index: number },
nextFrame: () => Promise<void>
) {
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
await fromLoc.dragTo(toLoc)
await nextFrame()
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
await expect(slot).toBeVisible()
const start = await getCenter(slot)
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
expect(samplerNode && clipNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkDetails = await getInputLinkDetails(
comfyPage.page,
clipNode.id,
0
)
expect(graphLinkDetails).toBeNull()
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
})
test('should reuse the existing origin when dragging an input link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 100
}
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-reuses-origin.png'
)
await comfyMouse.drop()
})
test('ctrl+alt drag from an input starts a fresh link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const dragTarget = {
x: vaeInputCenter.x + 140,
y: vaeInputCenter.y - 110
}
await comfyMouse.move(vaeInputCenter)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Alt')
try {
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-ctrl-alt.png'
)
} finally {
await comfyMouse.drop().catch(() => {})
await comfyPage.page.keyboard.up('Alt').catch(() => {})
await comfyPage.page.keyboard.up('Control').catch(() => {})
}
await comfyPage.nextFrame()
// Tcehnically intended to disconnect existing as well
expect(await vaeInput.getLinkCount()).toBe(0)
expect(await samplerOutput.getLinkCount()).toBe(0)
})
test('dropping an input link back on its slot restores the original connection', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
try {
await comfyMouse.drag(vaeInputCenter)
} finally {
await comfyMouse.drop()
}
await comfyPage.nextFrame()
const originalLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(originalLink).not.toBeNull()
const dragTarget = {
x: vaeInputCenter.x + 150,
y: vaeInputCenter.y - 100
}
// To prevent needing a screenshot expectation for whether the link's off
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
const inputBox = await vaeInputLocator.boundingBox()
if (!inputBox) throw new Error('Input slot bounding box not available')
const isOutsideX =
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
const isOutsideY =
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
expect(isOutsideX || isOutsideY).toBe(true)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const restoredLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(restoredLink).not.toBeNull()
if (!restoredLink || !originalLink) {
throw new Error('Expected both original and restored links to exist')
}
expect(restoredLink).toMatchObject({
originId: originalLink.originId,
originSlot: originalLink.originSlot,
targetId: originalLink.targetId,
targetSlot: originalLink.targetSlot,
parentId: originalLink.parentId
})
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
})
test('rerouted input drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 120
}
let dropped = false
try {
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-input-drag.png'
)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
dropped = true
} finally {
if (!dropped) {
await comfyMouse.drop().catch(() => {})
}
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('rerouted output shift-drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 150,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
dropPending = true
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-output-shift-drag.png'
)
await comfyMouse.move(outputCenter)
await comfyMouse.drop()
dropPending = false
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('dragging input to input drags existing link', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
// Verify initial link exists between CLIP -> KSampler input[1]
const initialLink = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(initialLink).not.toBeNull()
expect(initialLink).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 1
})
// Step 2: Drag from KSampler's second input to its third input (index 2)
const input2Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
1,
true
)
const input3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(input2Center)
await comfyMouse.drag(input3Center)
await comfyMouse.drop()
await comfyPage.nextFrame()
// Expect old link removed from input[1]
const afterSecondInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(afterSecondInput).toBeNull()
// Expect new link exists at input[2] from CLIP
const afterThirdInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(afterThirdInput).not.toBeNull()
expect(afterThirdInput).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
test('shift-dragging an output with multiple links should drag all links', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
const clipOutput = await clipNode.getOutput(0)
// Connect output[0] -> inputs[1] and [2]
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 2 },
() => comfyPage.nextFrame()
)
expect(await clipOutput.getLinkCount()).toBe(2)
const outputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 40,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
await comfyMouse.drag(dragTarget)
dropPending = true
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-shift-output-multi-link.png'
)
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
})
})

View File

@@ -1,76 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
await expect(editingTitleInput).not.toBeVisible()
})
})

View File

@@ -0,0 +1,221 @@
import type { Locator } from '@playwright/test'
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { fitToViewInstant } from '../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const outputSlot = await samplerNode.getOutput(0)
await outputSlot.removeLinks()
await comfyPage.nextFrame()
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
await expect(slotLocator).toBeVisible()
const start = await getCenter(slotLocator)
const canvasBox = await comfyPage.canvas.boundingBox()
if (!canvasBox) throw new Error('Canvas bounding box not available')
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
expect(vaeNodes.length).toBeGreaterThan(0)
const vaeNode = vaeNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const source = graph.getNodeById(sourceId)
if (!source) return null
const linkId = source.outputs[0]?.links?.[0]
if (linkId == null) return null
const link = graph.links[linkId]
if (!link) return null
return {
originId: link.origin_id,
originSlot: link.origin_slot,
targetId: link.target_id,
targetSlot: link.target_slot
}
}, samplerNode.id)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
expect(clipNodes.length).toBeGreaterThan(0)
const clipNode = clipNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,7 +1,7 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
} from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,8 +15,7 @@ test.describe('Vue Node Selection', () => {
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' },
{ key: 'Meta', name: 'meta' }
{ key: 'Shift', name: 'shift' }
] as const
for (const { key: modifier, name } of modifiers) {

View File

@@ -1,49 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Node Custom Colors', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('displays color picker button and allows color selection', async ({
comfyPage
}) => {
const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'Load Checkpoint'
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-color-blue.png'
)
})
test('should load node colors from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-dark-all-colors.png'
)
})
test('should show brightened node colors on light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-light-all-colors.png'
)
})
})

View File

@@ -1,85 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
test.describe('Vue Node Pin', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling pin on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator).not.toBeVisible()
})
test('should allow toggling pin on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const pinIndicator1 = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator1).toBeVisible()
const pinIndicator2 = ksamplerNode.locator(PIN_INDICATOR)
await expect(pinIndicator2).toBeVisible()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
await expect(pinIndicator1).not.toBeVisible()
await expect(pinIndicator2).not.toBeVisible()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is not dragged (same position before and after click-and-drag)
const headerPosAfterDrag = await checkpointNodeHeader.boundingBox()
if (!headerPosAfterDrag)
throw new Error('Failed to get header position after drag')
expect(headerPosAfterDrag).toEqual(headerPos)
// Unpin the node with the hotkey
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
// Verify the node is dragged
const headerPosAfterDrag2 = await checkpointNodeHeader.boundingBox()
if (!headerPosAfterDrag2)
throw new Error('Failed to get header position after drag')
expect(headerPosAfterDrag2).not.toEqual(headerPos)
})
})

View File

@@ -1,21 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
})
})

View File

@@ -1,49 +0,0 @@
import {
type ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
test('should allow entering text', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
await textarea.fill('Hello World')
await expect(textarea).toHaveValue('Hello World')
await textarea.fill('Hello World 2')
await expect(textarea).toHaveValue('Hello World 2')
})
test('should support entering multiline content', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const multilineValue = ['Line 1', 'Line 2', 'Line 3'].join('\n')
await textarea.fill(multilineValue)
await expect(textarea).toHaveValue(multilineValue)
})
test('should retain value after focus changes', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
await textarea.fill('Keep me around')
// Click another node
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await loadCheckpointNode.click()
await getFirstClipNode(comfyPage).click()
await expect(textarea).toHaveValue('Keep me around')
})
})

View File

@@ -18,7 +18,7 @@ export default defineConfig([
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*'
@@ -181,31 +181,5 @@ export default defineConfig([
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message: '.spec.ts files are only allowed under browser_tests/tests/'
}
]
}
},
{
files: ['browser_tests/tests/**/*.test.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Program',
message:
'.test.ts files are not allowed in browser_tests/tests/; use .spec.ts instead'
}
]
}
}
])

View File

@@ -1,30 +1,15 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
workspaces: {
'.': {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/design-system': {
entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
},
'packages/registry-types': {
entry: ['src/comfyRegistryTypes.ts'],
project: ['src/**/*.{js,ts}']
}
},
ignoreBinaries: ['python3'],
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
ignoreBinaries: ['only-allow', 'openapi-typescript'],
ignoreDependencies: [
// Weird importmap things
'@iconify/json',
@@ -38,7 +23,7 @@ const config: KnipConfig = {
ignore: [
// Auto generated manager types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
],

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.3",
"version": "1.28.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -38,8 +38,7 @@
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"storybook": "nx storybook -p 6006",
"build-storybook": "storybook build",
"devtools:pycheck": "python3 -m compileall -q tools/devtools"
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
@@ -107,7 +106,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/schemas": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
@@ -160,4 +159,4 @@
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "@comfyorg/registry-types",
"version": "1.0.0",
"description": "Comfy Registry API TypeScript types",
"packageManager": "pnpm@10.17.1",
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "@comfyorg/schemas",
"version": "1.0.0",
"type": "module",
"description": "Shared Zod schemas for ComfyUI Frontend",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./apiSchema": "./src/apiSchema.ts",
"./colorPaletteSchema": "./src/colorPaletteSchema.ts",
"./keyBindingSchema": "./src/keyBindingSchema.ts",
"./nodeDefSchema": "./src/nodeDefSchema.ts",
"./nodeDef/nodeDefSchemaV2": "./src/nodeDef/nodeDefSchemaV2.ts",
"./nodeDef/migration": "./src/nodeDef/migration.ts",
"./signInSchema": "./src/signInSchema.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"nx": {
"tags": [
"scope:shared",
"type:schema"
]
},
"dependencies": {
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,526 @@
import { z } from 'zod'
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
import {
zComfyWorkflow,
zNodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
import { zKeybinding } from '@/schemas/keyBindingSchema'
import { NodeBadgeMode } from '@/types/nodeSource'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional()
})
.passthrough()
// WS messages
const zStatusWsMessageStatus = z.object({
exec_info: z.object({
queue_remaining: z.number().int()
})
})
const zStatusWsMessage = z.object({
status: zStatusWsMessageStatus.nullish(),
sid: z.string().nullish()
})
const zProgressWsMessage = z.object({
value: z.number().int(),
max: z.number().int(),
prompt_id: zPromptId,
node: zNodeId
})
const zNodeProgressState = z.object({
value: z.number(),
max: z.number(),
state: z.enum(['pending', 'running', 'finished', 'error']),
node_id: zNodeId,
prompt_id: zPromptId,
display_node_id: zNodeId.optional(),
parent_node_id: zNodeId.optional(),
real_node_id: zNodeId.optional()
})
const zProgressStateWsMessage = z.object({
prompt_id: zPromptId,
nodes: z.record(zNodeId, zNodeProgressState)
})
const zExecutingWsMessage = z.object({
node: zNodeId,
display_node: zNodeId,
prompt_id: zPromptId
})
const zExecutedWsMessage = zExecutingWsMessage.extend({
output: zOutputs,
merge: z.boolean().optional()
})
const zExecutionWsMessageBase = z.object({
prompt_id: zPromptId,
timestamp: z.number().int()
})
const zExecutionStartWsMessage = zExecutionWsMessageBase
const zExecutionSuccessWsMessage = zExecutionWsMessageBase
const zExecutionCachedWsMessage = zExecutionWsMessageBase.extend({
nodes: z.array(zNodeId)
})
const zExecutionInterruptedWsMessage = zExecutionWsMessageBase.extend({
node_id: zNodeId,
node_type: zNodeType,
executed: z.array(zNodeId)
})
const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
node_id: zNodeId,
node_type: zNodeType,
executed: z.array(zNodeId),
exception_message: z.string(),
exception_type: z.string(),
traceback: z.array(z.string()),
current_inputs: z.any(),
current_outputs: z.any()
})
const zProgressTextWsMessage = z.object({
nodeId: zNodeId,
text: z.string()
})
const zDisplayComponentWsMessage = z.object({
node_id: zNodeId,
component: z.enum(['ChatHistoryWidget']),
props: z.record(z.string(), z.any()).optional()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
})
const zLogEntry = z.object({
t: z.string(),
m: z.string()
})
const zLogsWsMessage = z.object({
size: zTerminalSize.optional(),
entries: z.array(zLogEntry)
})
const zLogRawResponse = z.object({
size: zTerminalSize,
entries: z.array(zLogEntry)
})
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
export type ExecutingWsMessage = z.infer<typeof zExecutingWsMessage>
export type ExecutedWsMessage = z.infer<typeof zExecutedWsMessage>
export type ExecutionStartWsMessage = z.infer<typeof zExecutionStartWsMessage>
export type ExecutionSuccessWsMessage = z.infer<
typeof zExecutionSuccessWsMessage
>
export type ExecutionCachedWsMessage = z.infer<typeof zExecutionCachedWsMessage>
export type ExecutionInterruptedWsMessage = z.infer<
typeof zExecutionInterruptedWsMessage
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages
const zPromptInputItem = z.object({
inputs: z.record(z.string(), z.any()),
class_type: zNodeType
})
const zPromptInputs = z.record(zPromptInputItem)
const zExtraPngInfo = z
.object({
workflow: zComfyWorkflow
})
.passthrough()
const zExtraData = z.object({
/** extra_pnginfo can be missing is backend execution gets a validation error. */
extra_pnginfo: zExtraPngInfo.optional(),
client_id: z.string()
})
const zOutputsToExecute = z.array(zNodeId)
const zExecutionStartMessage = z.tuple([
z.literal('execution_start'),
zExecutionStartWsMessage
])
const zExecutionSuccessMessage = z.tuple([
z.literal('execution_success'),
zExecutionSuccessWsMessage
])
const zExecutionCachedMessage = z.tuple([
z.literal('execution_cached'),
zExecutionCachedWsMessage
])
const zExecutionInterruptedMessage = z.tuple([
z.literal('execution_interrupted'),
zExecutionInterruptedWsMessage
])
const zExecutionErrorMessage = z.tuple([
z.literal('execution_error'),
zExecutionErrorWsMessage
])
const zStatusMessage = z.union([
zExecutionStartMessage,
zExecutionSuccessMessage,
zExecutionCachedMessage,
zExecutionInterruptedMessage,
zExecutionErrorMessage
])
const zStatus = z.object({
status_str: z.enum(['success', 'error']),
completed: z.boolean(),
messages: z.array(zStatusMessage)
})
const zTaskPrompt = z.tuple([
zQueueIndex,
zPromptId,
zPromptInputs,
zExtraData,
zOutputsToExecute
])
const zRunningTaskItem = z.object({
taskType: z.literal('Running'),
prompt: zTaskPrompt,
// @Deprecated
remove: z.object({
name: z.literal('Cancel'),
cb: z.function()
})
})
const zPendingTaskItem = z.object({
taskType: z.literal('Pending'),
prompt: zTaskPrompt
})
const zTaskOutput = z.record(zNodeId, zOutputs)
const zNodeOutputsMeta = z.object({
node_id: zNodeId,
display_node: zNodeId,
prompt_id: zPromptId.optional(),
read_node_id: zNodeId.optional()
})
const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
const zHistoryTaskItem = z.object({
taskType: z.literal('History'),
prompt: zTaskPrompt,
status: zStatus.optional(),
outputs: zTaskOutput,
meta: zTaskMeta.optional()
})
const zTaskItem = z.union([
zRunningTaskItem,
zPendingTaskItem,
zHistoryTaskItem
])
const zTaskType = z.union([
z.literal('Running'),
z.literal('Pending'),
z.literal('History')
])
export type TaskType = z.infer<typeof zTaskType>
export type TaskPrompt = z.infer<typeof zTaskPrompt>
export type TaskStatus = z.infer<typeof zStatus>
export type TaskOutput = z.infer<typeof zTaskOutput>
// `/queue`
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
// `/history`
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
export type TaskItem = z.infer<typeof zTaskItem>
const zEmbeddingsResponse = z.array(z.string())
const zExtensionsResponse = z.array(z.string())
const zError = z.object({
type: z.string(),
message: z.string(),
details: z.string(),
extra_info: z
.object({
input_name: z.string().optional()
})
.passthrough()
.optional()
})
const zNodeError = z.object({
errors: z.array(zError),
class_type: z.string(),
dependent_outputs: z.array(z.any())
})
const zPromptResponse = z.object({
node_errors: z.record(zNodeId, zNodeError).optional(),
prompt_id: z.string().optional(),
exec_info: z
.object({
queue_remaining: z.number().optional()
})
.optional(),
error: z.union([z.string(), zError])
})
const zDeviceStats = z.object({
name: z.string(),
type: z.string(),
index: z.number(),
vram_total: z.number(),
vram_free: z.number(),
torch_vram_total: z.number(),
torch_vram_free: z.number()
})
const zSystemStats = z.object({
system: z.object({
os: z.string(),
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
ram_total: z.number(),
ram_free: z.number()
}),
devices: z.array(zDeviceStats)
})
const zUser = z.object({
storage: z.enum(['server']),
// `migrated` is only available in single-user mode.
migrated: z.boolean().optional(),
// `users` is only available in multi-user server mode.
users: z.record(z.string(), z.string()).optional()
})
const zUserData = z.array(z.array(z.string(), z.string()))
const zUserDataFullInfo = z.object({
path: z.string(),
size: z.number(),
modified: z.number()
})
const zBookmarkCustomization = z.object({
icon: z.string().optional(),
color: z.string().optional()
})
export type BookmarkCustomization = z.infer<typeof zBookmarkCustomization>
const zLinkReleaseTriggerAction = z.enum(
Object.values(LinkReleaseTriggerAction) as [string, ...string[]]
)
const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
'Comfy.DisableFloatRounding': z.boolean(),
'Comfy.DisableSliders': z.boolean(),
'Comfy.DOMClippingEnabled': z.boolean(),
'Comfy.EditAttention.Delta': z.number(),
'Comfy.EnableTooltips': z.boolean(),
'Comfy.EnableWorkflowViewRestore': z.boolean(),
'Comfy.FloatRoundingPrecision': z.number(),
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
'Comfy.GroupSelectedNodes.Padding': z.number(),
'Comfy.Locale': z.string(),
'Comfy.NodeLibrary.Bookmarks': z.array(z.string()),
'Comfy.NodeLibrary.Bookmarks.V2': z.array(z.string()),
'Comfy.NodeLibrary.BookmarksCustomization': z.record(
z.string(),
zBookmarkCustomization
),
'Comfy.LinkRelease.Action': zLinkReleaseTriggerAction,
'Comfy.LinkRelease.ActionShift': zLinkReleaseTriggerAction,
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),
'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']),
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
'Comfy.NodeSearchBoxImpl': z.enum(['default', 'simple']),
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowIdName': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency': z.boolean(),
'Comfy.NodeSuggestions.number': z.number(),
'Comfy.Node.BypassAllLinksOnDelete': z.boolean(),
'Comfy.Node.Opacity': z.number(),
'Comfy.Node.MiddleClickRerouteNode': z.boolean(),
'Comfy.Node.ShowDeprecated': z.boolean(),
'Comfy.Node.ShowExperimental': z.boolean(),
'Comfy.Pointer.ClickBufferTime': z.number(),
'Comfy.Pointer.ClickDrift': z.number(),
'Comfy.Pointer.DoubleClickTime': z.number(),
'Comfy.PreviewFormat': z.string(),
'Comfy.PromptFilename': z.boolean(),
'Comfy.Sidebar.Location': z.enum(['left', 'right']),
'Comfy.Sidebar.Size': z.enum(['small', 'normal']),
'Comfy.Sidebar.UnifiedWidth': z.boolean(),
'Comfy.SnapToGrid.GridSize': z.number(),
'Comfy.TextareaWidget.FontSize': z.number(),
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
'Comfy.UseNewMenu': z.enum(['Disabled', 'Top', 'Bottom']),
'Comfy.TreeExplorer.ItemPadding': z.number(),
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Workflow.WorkflowTabsPosition': z.enum([
'Sidebar',
'Topbar',
'Topbar (2nd-row)'
]),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.WidgetControlMode': z.enum(['before', 'after']),
'Comfy.Window.UnloadConfirmation': z.boolean(),
'Comfy.NodeBadge.NodeSourceBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
'Comfy.Notification.ShowVersionUpdates': z.boolean(),
'Comfy.QueueButton.BatchCountLimit': z.number(),
'Comfy.Queue.MaxHistoryItems': z.number(),
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
'Comfy.Extension.Disabled': z.array(z.string()),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
'Comfy.Node.SnapHighlightsNode': z.boolean(),
'Comfy.Server.ServerConfigValues': z.record(z.string(), z.any()),
'Comfy.Server.LaunchArgs': z.record(z.string(), z.string()),
'LiteGraph.Canvas.MaximumFps': z.number(),
'Comfy.Workflow.ConfirmDelete': z.boolean(),
'Comfy.Workflow.AutoSaveDelay': z.number(),
'Comfy.Workflow.AutoSave': z.enum(['off', 'after delay']),
'Comfy.RerouteBeta': z.boolean(),
'LiteGraph.Canvas.MinFontSizeForLOD': z.number(),
'Comfy.Canvas.SelectionToolbox': z.boolean(),
'LiteGraph.Node.TooltipDelay': z.number(),
'LiteGraph.ContextMenu.Scaling': z.boolean(),
'LiteGraph.Reroute.SplineOffset': z.number(),
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(),
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
'Comfy.Workflow.Persist': z.boolean(),
'Comfy.TutorialCompleted': z.boolean(),
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),
'Comfy.Minimap.NodeColors': z.boolean(),
'Comfy.Minimap.ShowLinks': z.boolean(),
'Comfy.Minimap.ShowGroups': z.boolean(),
'Comfy.Minimap.RenderBypassState': z.boolean(),
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
'Comfy.Canvas.MouseWheelScroll': z.string(),
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),
'Comfy-Desktop.UV.PythonInstallMirror': z.string(),
'Comfy-Desktop.UV.PypiInstallMirror': z.string(),
'Comfy-Desktop.UV.TorchInstallMirror': z.string(),
'Comfy.MaskEditor.UseNewEditor': z.boolean(),
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
'Comfy.Load3D.ShowGrid': z.boolean(),
'Comfy.Load3D.ShowPreview': z.boolean(),
'Comfy.Load3D.BackgroundColor': z.string(),
'Comfy.Load3D.LightIntensity': z.number(),
'Comfy.Load3D.LightIntensityMaximum': z.number(),
'Comfy.Load3D.LightIntensityMinimum': z.number(),
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
'Comfy.Load3D.3DViewerEnable': z.boolean(),
'Comfy.Memory.AllowManualUnload': z.boolean(),
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.string(),
/** Release data settings */
'Comfy.Release.Version': z.string(),
'Comfy.Release.Status': z.enum([
'skipped',
'changelog seen',
"what's new seen"
]),
'Comfy.Release.Timestamp': z.number(),
/** Settings used for testing */
'test.setting': z.any(),
'main.sub.setting.name': z.any(),
'single.setting': z.any(),
'LiteGraph.Node.DefaultPadding': z.boolean(),
'LiteGraph.Pointer.TrackpadGestures': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
export type PromptResponse = z.infer<typeof zPromptResponse>
export type NodeError = z.infer<typeof zNodeError>
export type Settings = z.infer<typeof zSettings>
export type DeviceStats = z.infer<typeof zDeviceStats>
export type SystemStats = z.infer<typeof zSystemStats>
export type User = z.infer<typeof zUser>
export type UserData = z.infer<typeof zUserData>
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
export type TerminalSize = z.infer<typeof zTerminalSize>
export type LogEntry = z.infer<typeof zLogEntry>
export type LogsRawResponse = z.infer<typeof zLogRawResponse>

View File

@@ -0,0 +1,116 @@
import { z } from 'zod'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const nodeSlotSchema = z.object({
CLIP: z.string(),
CLIP_VISION: z.string(),
CLIP_VISION_OUTPUT: z.string(),
CONDITIONING: z.string(),
CONTROL_NET: z.string(),
IMAGE: z.string(),
LATENT: z.string(),
MASK: z.string(),
MODEL: z.string(),
STYLE_MODEL: z.string(),
VAE: z.string(),
NOISE: z.string(),
GUIDER: z.string(),
SAMPLER: z.string(),
SIGMAS: z.string(),
TAESD: z.string()
})
const litegraphBaseSchema = z.object({
BACKGROUND_IMAGE: z.string(),
CLEAR_BACKGROUND_COLOR: z.string(),
NODE_TITLE_COLOR: z.string(),
NODE_SELECTED_TITLE_COLOR: z.string(),
NODE_TEXT_SIZE: z.number(),
NODE_TEXT_COLOR: z.string(),
NODE_TEXT_HIGHLIGHT_COLOR: z.string(),
NODE_SUBTEXT_SIZE: z.number(),
NODE_DEFAULT_COLOR: z.string(),
NODE_DEFAULT_BGCOLOR: z.string(),
NODE_DEFAULT_BOXCOLOR: z.string(),
NODE_DEFAULT_SHAPE: z.union([
z.literal(LiteGraph.BOX_SHAPE),
z.literal(LiteGraph.ROUND_SHAPE),
z.literal(LiteGraph.CARD_SHAPE),
// Legacy palettes have string field for NODE_DEFAULT_SHAPE.
z.string()
]),
NODE_BOX_OUTLINE_COLOR: z.string(),
NODE_BYPASS_BGCOLOR: z.string(),
NODE_ERROR_COLOUR: z.string(),
DEFAULT_SHADOW_COLOR: z.string(),
DEFAULT_GROUP_FONT: z.number(),
WIDGET_BGCOLOR: z.string(),
WIDGET_OUTLINE_COLOR: z.string(),
WIDGET_TEXT_COLOR: z.string(),
WIDGET_SECONDARY_TEXT_COLOR: z.string(),
WIDGET_DISABLED_TEXT_COLOR: z.string(),
LINK_COLOR: z.string(),
EVENT_LINK_COLOR: z.string(),
CONNECTING_LINK_COLOR: z.string(),
BADGE_FG_COLOR: z.string(),
BADGE_BG_COLOR: z.string()
})
const comfyBaseSchema = z.object({
['fg-color']: z.string(),
['bg-color']: z.string(),
['bg-img']: z.string().optional(),
['comfy-menu-bg']: z.string(),
['comfy-menu-secondary-bg']: z.string(),
['comfy-input-bg']: z.string(),
['input-text']: z.string(),
['descrip-text']: z.string(),
['drag-text']: z.string(),
['error-text']: z.string(),
['border-color']: z.string(),
['tr-even-bg-color']: z.string(),
['tr-odd-bg-color']: z.string(),
['content-bg']: z.string(),
['content-fg']: z.string(),
['content-hover-bg']: z.string(),
['content-hover-fg']: z.string(),
['bar-shadow']: z.string()
})
const colorsSchema = z.object({
node_slot: nodeSlotSchema,
litegraph_base: litegraphBaseSchema,
comfy_base: comfyBaseSchema
})
const partialColorsSchema = z.object({
node_slot: nodeSlotSchema.partial(),
litegraph_base: litegraphBaseSchema.partial(),
comfy_base: comfyBaseSchema.partial()
})
// Palette in the wild can have custom metadata fields such as 'version'.
export const paletteSchema = z
.object({
id: z.string(),
name: z.string(),
colors: partialColorsSchema,
light_theme: z.boolean().optional()
})
.passthrough()
const completedPaletteSchema = z
.object({
id: z.string(),
name: z.string(),
colors: colorsSchema
})
.passthrough()
export const colorPalettesSchema = z.record(paletteSchema)
export type Colors = z.infer<typeof colorsSchema>
export type Palette = z.infer<typeof paletteSchema>
export type CompletedPalette = z.infer<typeof completedPaletteSchema>
export type ColorPalettes = z.infer<typeof colorPalettesSchema>

View File

@@ -0,0 +1,25 @@
import { z } from 'zod'
// KeyCombo schema
const zKeyCombo = z.object({
key: z.string(),
ctrl: z.boolean().optional(),
alt: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional()
})
// Keybinding schema
export const zKeybinding = z.object({
commandId: z.string(),
combo: zKeyCombo,
// Optional target element ID to limit keybinding to.
// Note: Currently only used to distinguish between global keybindings
// and litegraph canvas keybindings.
// Do NOT use this field in extensions as it has no effect.
targetElementId: z.string().optional()
})
// Infer types from schemas
export type KeyCombo = z.infer<typeof zKeyCombo>
export type Keybinding = z.infer<typeof zKeybinding>

View File

@@ -0,0 +1,143 @@
import type {
ComfyNodeDef as ComfyNodeDefV2,
InputSpec as InputSpecV2,
OutputSpec as OutputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComfyNodeDef as ComfyNodeDefV1,
InputSpec as InputSpecV1
} from '@/schemas/nodeDefSchema'
import {
getComboSpecComboOptions,
isComboInputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
/**
* Transforms a V1 node definition to V2 format
* @param nodeDefV1 The V1 node definition to transform
* @returns The transformed V2 node definition
*/
export function transformNodeDefV1ToV2(
nodeDefV1: ComfyNodeDefV1
): ComfyNodeDefV2 {
// Transform inputs
const inputs: Record<string, InputSpecV2> = {}
// Process required inputs
if (nodeDefV1.input?.required) {
Object.entries(nodeDefV1.input.required).forEach(([name, inputSpecV1]) => {
inputs[name] = transformInputSpecV1ToV2(inputSpecV1, {
name,
isOptional: false
})
})
}
// Process optional inputs
if (nodeDefV1.input?.optional) {
Object.entries(nodeDefV1.input.optional).forEach(([name, inputSpecV1]) => {
inputs[name] = transformInputSpecV1ToV2(inputSpecV1, {
name,
isOptional: true
})
})
}
// Transform outputs
const outputs: OutputSpecV2[] = []
if (nodeDefV1.output) {
if (Array.isArray(nodeDefV1.output)) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
outputs.push(outputSpec)
})
} else {
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
}
}
// Create the V2 node definition
return {
inputs,
outputs,
hidden: nodeDefV1.input?.hidden,
name: nodeDefV1.name,
display_name: nodeDefV1.display_name,
description: nodeDefV1.description,
category: nodeDefV1.category,
output_node: nodeDefV1.output_node,
python_module: nodeDefV1.python_module,
deprecated: nodeDefV1.deprecated,
experimental: nodeDefV1.experimental
}
}
/**
* Transforms a V1 input specification to V2 format
* @param inputSpecV1 The V1 input specification to transform
* @param name The name of the input
* @param isOptional Whether the input is optional
* @returns The transformed V2 input specification
*/
export function transformInputSpecV1ToV2(
inputSpecV1: InputSpecV1,
kwargs: {
name: string
isOptional?: boolean
}
): InputSpecV2 {
const { name, isOptional = false } = kwargs
// Extract options from the input spec
const options = inputSpecV1[1] || {}
// Base properties for all input types
const baseProps = {
name,
isOptional,
...options
}
// Handle different input types
if (isComboInputSpec(inputSpecV1)) {
return {
type: 'COMBO',
...baseProps,
options: isComboInputSpecV1(inputSpecV1)
? inputSpecV1[0]
: getComboSpecComboOptions(inputSpecV1)
}
} else if (typeof inputSpecV1[0] === 'string') {
// Handle standard types (INT, FLOAT, BOOLEAN, STRING) and custom types
return {
type: inputSpecV1[0],
...baseProps
}
}
// Fallback for any unhandled cases
return {
type: 'UNKNOWN',
...baseProps
}
}
export function transformInputSpecV2ToV1(
inputSpecV2: InputSpecV2
): InputSpecV1 {
return [inputSpecV2.type, inputSpecV2]
}

View File

@@ -0,0 +1,264 @@
import { z } from 'zod'
import {
zBaseInputOptions,
zBooleanInputOptions,
zComboInputOptions,
zFloatInputOptions,
zIntInputOptions,
zStringInputOptions
} from '@/schemas/nodeDefSchema'
const zIntInputSpec = zIntInputOptions.extend({
type: z.literal('INT'),
name: z.string(),
isOptional: z.boolean().optional()
})
const zFloatInputSpec = zFloatInputOptions.extend({
type: z.literal('FLOAT'),
name: z.string(),
isOptional: z.boolean().optional()
})
const zBooleanInputSpec = zBooleanInputOptions.extend({
type: z.literal('BOOLEAN'),
name: z.string(),
isOptional: z.boolean().optional()
})
const zStringInputSpec = zStringInputOptions.extend({
type: z.literal('STRING'),
name: z.string(),
isOptional: z.boolean().optional()
})
const zComboInputSpec = zComboInputOptions.extend({
type: z.literal('COMBO'),
name: z.string(),
isOptional: z.boolean().optional()
})
const zColorInputSpec = zBaseInputOptions.extend({
type: z.literal('COLOR'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
default: z.string().optional()
})
.optional()
})
const zFileUploadInputSpec = zBaseInputOptions.extend({
type: z.literal('FILEUPLOAD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zImageInputSpec = zBaseInputOptions.extend({
type: z.literal('IMAGE'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zImageCompareInputSpec = zBaseInputOptions.extend({
type: z.literal('IMAGECOMPARE'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zMarkdownInputSpec = zBaseInputOptions.extend({
type: z.literal('MARKDOWN'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
content: z.string().optional()
})
.optional()
})
const zTreeSelectInputSpec = zBaseInputOptions.extend({
type: z.literal('TREESELECT'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
multiple: z.boolean().optional(),
values: z.array(z.unknown()).optional()
})
.optional()
})
const zMultiSelectInputSpec = zBaseInputOptions.extend({
type: z.literal('MULTISELECT'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
values: z.array(z.string()).optional()
})
.optional()
})
const zChartInputSpec = zBaseInputOptions.extend({
type: z.literal('CHART'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
type: z.enum(['bar', 'line']).optional(),
data: z.object({}).optional()
})
.optional()
})
const zGalleriaInputSpec = zBaseInputOptions.extend({
type: z.literal('GALLERIA'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
images: z.array(z.string()).optional()
})
.optional()
})
const zSelectButtonInputSpec = zBaseInputOptions.extend({
type: z.literal('SELECTBUTTON'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
values: z.array(z.string()).optional()
})
.optional()
})
const zTextareaInputSpec = zBaseInputOptions.extend({
type: z.literal('TEXTAREA'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
rows: z.number().optional(),
cols: z.number().optional(),
default: z.string().optional()
})
.optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
isOptional: z.boolean().optional()
})
const zInputSpec = z.union([
zIntInputSpec,
zFloatInputSpec,
zBooleanInputSpec,
zStringInputSpec,
zComboInputSpec,
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
zMultiSelectInputSpec,
zChartInputSpec,
zGalleriaInputSpec,
zSelectButtonInputSpec,
zTextareaInputSpec,
zCustomInputSpec
])
// Output specs
const zOutputSpec = z.object({
index: z.number(),
name: z.string(),
type: z.string(),
is_list: z.boolean(),
options: z.array(z.any()).optional(),
tooltip: z.string().optional()
})
// Main node definition schema
export const zComfyNodeDef = z.object({
inputs: z.record(zInputSpec),
outputs: z.array(zOutputSpec),
hidden: z.record(z.any()).optional(),
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
deprecated: z.boolean().optional(),
experimental: z.boolean().optional(),
api_node: z.boolean().optional()
})
// Export types
type IntInputSpec = z.infer<typeof zIntInputSpec>
type FloatInputSpec = z.infer<typeof zFloatInputSpec>
type BooleanInputSpec = z.infer<typeof zBooleanInputSpec>
type StringInputSpec = z.infer<typeof zStringInputSpec>
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
export type FileUploadInputSpec = z.infer<typeof zFileUploadInputSpec>
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
export type TreeSelectInputSpec = z.infer<typeof zTreeSelectInputSpec>
export type MultiSelectInputSpec = z.infer<typeof zMultiSelectInputSpec>
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export const isIntInputSpec = (
inputSpec: InputSpec
): inputSpec is IntInputSpec => {
return inputSpec.type === 'INT'
}
export const isFloatInputSpec = (
inputSpec: InputSpec
): inputSpec is FloatInputSpec => {
return inputSpec.type === 'FLOAT'
}
export const isBooleanInputSpec = (
inputSpec: InputSpec
): inputSpec is BooleanInputSpec => {
return inputSpec.type === 'BOOLEAN'
}
export const isStringInputSpec = (
inputSpec: InputSpec
): inputSpec is StringInputSpec => {
return inputSpec.type === 'STRING'
}
export const isComboInputSpec = (
inputSpec: InputSpec
): inputSpec is ComboInputSpec => {
return inputSpec.type === 'COMBO'
}
export const isChartInputSpec = (
inputSpec: InputSpec
): inputSpec is ChartInputSpec => {
return inputSpec.type === 'CHART'
}

View File

@@ -0,0 +1,255 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
query_params: z.record(z.string(), z.string()).optional(),
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
})
export const zBaseInputOptions = z
.object({
default: z.any().optional(),
defaultInput: z.boolean().optional(),
forceInput: z.boolean().optional(),
tooltip: z.string().optional(),
hidden: z.boolean().optional(),
advanced: z.boolean().optional(),
widgetType: z.string().optional(),
/** Backend-only properties. */
rawLink: z.boolean().optional(),
lazy: z.boolean().optional()
})
.passthrough()
const zNumericInputOptions = zBaseInputOptions.extend({
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
default: z.union([z.number(), z.array(z.number())]).optional(),
display: z.enum(['slider', 'number', 'knob']).optional()
})
export const zIntInputOptions = zNumericInputOptions.extend({
/**
* If true, a linked widget will be added to the node to select the mode
* of `control_after_generate`.
*/
control_after_generate: z.boolean().optional()
})
export const zFloatInputOptions = zNumericInputOptions.extend({
round: z.union([z.number(), z.literal(false)]).optional()
})
export const zBooleanInputOptions = zBaseInputOptions.extend({
label_on: z.string().optional(),
label_off: z.string().optional(),
default: z.boolean().optional()
})
export const zStringInputOptions = zBaseInputOptions.extend({
default: z.string().optional(),
multiline: z.boolean().optional(),
dynamicPrompts: z.boolean().optional(),
// Multiline-only fields
defaultVal: z.string().optional(),
placeholder: z.string().optional()
})
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
audio_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
const zFloatInputSpec = z.tuple([
z.literal('FLOAT'),
zFloatInputOptions.optional()
])
const zBooleanInputSpec = z.tuple([
z.literal('BOOLEAN'),
zBooleanInputOptions.optional()
])
const zStringInputSpec = z.tuple([
z.literal('STRING'),
zStringInputOptions.optional()
])
/**
* Legacy combo syntax.
* @deprecated Use `zComboInputSpecV2` instead.
*/
const zComboInputSpec = z.tuple([
z.array(zComboOption),
zComboInputOptions.optional()
])
const zComboInputSpecV2 = z.tuple([
z.literal('COMBO'),
zComboInputOptions.optional()
])
export function isComboInputSpecV1(
inputSpec: InputSpec
): inputSpec is ComboInputSpec {
return Array.isArray(inputSpec[0])
}
export function isIntInputSpec(
inputSpec: InputSpec
): inputSpec is IntInputSpec {
return inputSpec[0] === 'INT'
}
export function isFloatInputSpec(
inputSpec: InputSpec
): inputSpec is FloatInputSpec {
return inputSpec[0] === 'FLOAT'
}
export function isComboInputSpecV2(
inputSpec: InputSpec
): inputSpec is ComboInputSpecV2 {
return inputSpec[0] === 'COMBO'
}
export function isComboInputSpec(
inputSpec: InputSpec
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
}
/**
* Get the type of an input spec.
*
* @param inputSpec - The input spec to get the type of.
* @returns The type of the input spec.
*/
export function getInputSpecType(inputSpec: InputSpec): string {
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
}
/**
* Get the combo options from a combo input spec.
*
* @param inputSpec - The input spec to get the combo options from.
* @returns The combo options.
*/
export function getComboSpecComboOptions(
inputSpec: ComboInputSpec | ComboInputSpecV2
): (number | string)[] {
return (
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
)
}
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
const zCustomInputSpec = z.tuple([
z.string().refine((value) => !excludedLiterals.has(value)),
zBaseInputOptions.optional()
])
const zInputSpec = z.union([
zIntInputSpec,
zFloatInputSpec,
zBooleanInputSpec,
zStringInputSpec,
zComboInputSpec,
zComboInputSpecV2,
zCustomInputSpec
])
const zComfyInputsSpec = z.object({
required: z.record(zInputSpec).optional(),
optional: z.record(zInputSpec).optional(),
// Frontend repo is not using it, but some custom nodes are using the
// hidden field to pass various values.
hidden: z.record(z.any()).optional()
})
const zComfyNodeDataType = z.string()
const zComfyComboOutput = z.array(zComboOption)
const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
output_is_list: z.array(z.boolean()).optional(),
output_name: z.array(z.string()).optional(),
output_tooltips: z.array(z.string()).optional(),
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
deprecated: z.boolean().optional(),
experimental: z.boolean().optional(),
/**
* Whether the node is an API node. Running API nodes requires login to
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional(),
/**
* Specifies the order of inputs for each input category.
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional()
})
// `/object_info`
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
export type IntInputSpec = z.infer<typeof zIntInputSpec>
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
export type InputSpec = z.infer<typeof zInputSpec>
export function validateComfyNodeDef(
data: any,
onError: (error: string) => void = console.warn
): ComfyNodeDef | null {
const result = zComfyNodeDef.safeParse(data)
if (!result.success) {
const zodError = fromZodError(result.error)
onError(
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
)
return null
}
return result.data
}

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { t } from '@/i18n'
export const apiKeySchema = z.object({
apiKey: z
.string()
.trim()
.startsWith('comfyui-', t('validation.prefix', { prefix: 'comfyui-' }))
.length(72, t('validation.length', { length: 72 }))
})
export const signInSchema = z.object({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
password: z.string().min(1, t('validation.required'))
})
export type SignInData = z.infer<typeof signInSchema>
const passwordSchema = z.object({
password: z
.string()
.min(8, t('validation.minLength', { length: 8 }))
.max(32, t('validation.maxLength', { length: 32 }))
.regex(/[A-Z]/, t('validation.password.uppercase'))
.regex(/[a-z]/, t('validation.password.lowercase'))
.regex(/\d/, t('validation.password.number'))
.regex(/[^A-Za-z0-9]/, t('validation.password.special')),
confirmPassword: z.string().min(1, t('validation.required'))
})
export const updatePasswordSchema = passwordSchema.refine(
(data) => data.password === data.confirmPassword,
{
message: t('validation.password.match'),
path: ['confirmPassword']
}
)
export const signUpSchema = passwordSchema
.extend({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
personalDataConsent: z.boolean()
})
.refine((data) => data.password === data.confirmPassword, {
message: t('validation.password.match'),
path: ['confirmPassword']
})
.refine((data) => data.personalDataConsent === true, {
message: t('validation.personalDataConsentRequired'),
path: ['personalDataConsent']
})
export type SignUpData = z.infer<typeof signUpSchema>

View File

@@ -1,22 +0,0 @@
{
"name": "@comfyorg/shared-frontend-utils",
"private": true,
"version": "1.0.0",
"description": "Shared frontend utils for ComfyUI Frontend",
"scripts": {
"typecheck": "tsc --noEmit"
},
"keywords": [],
"packageManager": "pnpm@10.17.1",
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
},
"dependencies": {
"axios": "^1.11.0"
},
"devDependencies": {
"typescript": "^5.9.2"
}
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

27
pnpm-lock.yaml generated
View File

@@ -20,9 +20,9 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/registry-types':
'@comfyorg/schemas':
specifier: workspace:*
version: link:packages/registry-types
version: link:packages/schemas
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
@@ -371,16 +371,17 @@ importers:
specifier: ^5.4.5
version: 5.9.2
packages/registry-types: {}
packages/shared-frontend-utils:
packages/schemas:
dependencies:
axios:
specifier: ^1.11.0
version: 1.11.0
zod:
specifier: ^3.23.8
version: 3.24.1
zod-validation-error:
specifier: ^3.3.0
version: 3.3.0(zod@3.24.1)
devDependencies:
typescript:
specifier: ^5.9.2
specifier: ^5.4.5
version: 5.9.2
packages/tailwind-utils:
@@ -6522,8 +6523,8 @@ packages:
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.1.0:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9081,7 +9082,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.0
vue-component-type-helpers: 3.0.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13879,7 +13880,7 @@ snapshots:
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.1.0: {}
vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -1,77 +0,0 @@
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: $0 [--debug]" >&2
}
debug=0
while [ "$#" -gt 0 ]; do
case "$1" in
--debug)
debug=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 2
;;
esac
shift
done
# Validate JSON syntax in tracked files using jq
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not installed" >&2
exit 127
fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
# shellcheck disable=SC2206
EXCLUDE_PATTERNS+=( ${JSON_LINT_EXCLUDES} )
fi
pathspecs=(-- '*.json')
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
if [[ ${pattern:0:1} == ':' ]]; then
pathspecs+=("$pattern")
else
pathspecs+=(":(glob,exclude)${pattern}")
fi
done
mapfile -t json_files < <(git ls-files "${pathspecs[@]}")
if [ "${#json_files[@]}" -eq 0 ]; then
echo 'No JSON files found.'
exit 0
fi
if [ "$debug" -eq 1 ]; then
echo 'JSON files to validate:'
printf ' %s\n' "${json_files[@]}"
fi
failed=0
for file in "${json_files[@]}"; do
if ! jq -e . "$file" >/dev/null; then
echo "Invalid JSON syntax: $file" >&2
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
echo 'JSON validation failed.' >&2
exit 1
fi
echo 'All JSON files are valid.'

View File

@@ -1,247 +0,0 @@
#!/bin/bash
set -e
# Deploy Storybook to Cloudflare Pages and comment on PR
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- STORYBOOK_BUILD_STATUS -->"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Deploy Storybook report, WARN: ensure inputs are sanitized before calling this function
deploy_storybook() {
dir="$1"
branch="$2"
[ ! -d "$dir" ] && echo "failed" && return
project="comfy-storybook"
echo "Deploying Storybook to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch is already sanitized, use it directly
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Check if this is a version-bump branch
IS_VERSION_BUMP="false"
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
IS_VERSION_BUMP="true"
fi
# Post starting comment with appropriate message
if [ "$IS_VERSION_BUMP" = "true" ]; then
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
else
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🌐 Preparing deployment to Cloudflare Pages...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
fi
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for Storybook build in: $(pwd)/storybook-static"
# Deploy Storybook if build exists
deployment_url="Not deployed"
if [ -d "storybook-static" ]; then
echo "Found Storybook build, deploying..."
url=$(deploy_storybook "storybook-static" "$cloudflare_branch")
if [ "$url" != "failed" ] && [ -n "$url" ]; then
deployment_url="[View Storybook]($url)"
else
deployment_url="Deployment failed"
fi
else
echo "Storybook build not found at storybook-static"
fi
# Get workflow conclusion from environment or default to success
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate completion comment based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Build completed successfully!"
footer_text="🎉 Your Storybook is ready for review!"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Build skipped."
footer_text=" Chromatic was skipped for this PR."
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Build cancelled."
footer_text=" The Chromatic run was cancelled."
else
status_icon="❌"
status_text="Build failed!"
footer_text="⚠️ Please check the workflow logs for error details."
fi
comment="$COMMENT_MARKER
## 🎨 Storybook Build Status
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 🔗 Links
- [📊 View Workflow Run]($WORKFLOW_URL)"
# Add deployment status
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
comment="$comment
- ❌ Storybook deployment failed"
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
comment="$comment
- 🎨 $deployment_url"
else
comment="$comment
- ⚠️ Build failed - $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
comment="$comment
- ⏭️ Storybook deployment skipped (build did not succeed)"
fi
comment="$comment
---
$footer_text"
post_comment "$comment"
fi

View File

@@ -1,15 +1,12 @@
import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import {
formatCamelCase,
normalizeI18nKey
} from '../packages/shared-frontend-utils/src/formatUtil'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
const commandsPath = './src/locales/en/commands.json'

View File

@@ -3,8 +3,8 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'

View File

@@ -93,7 +93,6 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -104,6 +103,7 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -189,8 +189,8 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
const allNodes = computed(() =>
Array.from(vueNodeLifecycle.vueNodeData.value.values())
)
watchEffect(() => {
@@ -225,6 +225,7 @@ watch(
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
@@ -292,36 +293,45 @@ watch(
{ deep: true }
)
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
// Update node slot errors
watch(
() => executionStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return
for (const node of comfyApp.graph.nodes) {
// Clear existing errors
const removeSlotError = (node: LGraphNode) => {
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
}
for (const node of comfyApp.graph.nodes) {
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
const slotErrorsChanged =
validErrors.length > 0 &&
validErrors.some((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
return true
}
return false
})
validErrors.forEach((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
})
// Trigger Vue node data update if slot errors changed
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
comfyApp.graph.onTrigger('node:slot-errors:changed', {
nodeId: node.id
})
}
}
comfyApp.canvas.draw(true, true)
@@ -354,6 +364,7 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()

Some files were not shown because too many files have changed in this diff Show More