Compare commits
22 Commits
fix/load-a
...
v1.36.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57a7d019b | ||
|
|
c52957abc9 | ||
|
|
1034c3c269 | ||
|
|
3339fc44f5 | ||
|
|
ce2e335d9e | ||
|
|
ef74c226fb | ||
|
|
61c6fe289e | ||
|
|
d3e2564036 | ||
|
|
a16db20e78 | ||
|
|
0863baafad | ||
|
|
e479b5f624 | ||
|
|
e5e8743798 | ||
|
|
b11e1e37ed | ||
|
|
b71cd7ab9a | ||
|
|
e12444abca | ||
|
|
b5fc8bde38 | ||
|
|
092533fb84 | ||
|
|
fd67ee070e | ||
|
|
1dc5d130ad | ||
|
|
13a287b1a8 | ||
|
|
b3ca1fb5a0 | ||
|
|
1d6d3fdc77 |
23
.github/actions/start-comfyui-server/action.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Start ComfyUI Server
|
||||||
|
description: 'Start ComfyUI server in a container environment (assumes ComfyUI is pre-installed)'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
front_end_root:
|
||||||
|
description: 'Path to frontend dist directory'
|
||||||
|
required: false
|
||||||
|
default: '$GITHUB_WORKSPACE/dist'
|
||||||
|
timeout:
|
||||||
|
description: 'Timeout in seconds for server startup'
|
||||||
|
required: false
|
||||||
|
default: '600'
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Copy devtools and start server
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||||
|
cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" &
|
||||||
|
wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }}
|
||||||
104
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -15,66 +15,56 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Setup Test Environment, build frontend but do not start server yet
|
|
||||||
- name: Setup ComfyUI server
|
|
||||||
uses: ./.github/actions/setup-comfyui-server
|
|
||||||
- name: Setup frontend
|
- name: Setup frontend
|
||||||
uses: ./.github/actions/setup-frontend
|
uses: ./.github/actions/setup-frontend
|
||||||
with:
|
with:
|
||||||
include_build_step: true
|
include_build_step: true
|
||||||
- name: Setup Playwright
|
|
||||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
|
||||||
|
|
||||||
# Save the entire workspace as cache for later test jobs to restore
|
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
|
||||||
- name: Generate cache key
|
- name: Upload built frontend
|
||||||
id: cache-key
|
uses: actions/upload-artifact@v4
|
||||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
|
||||||
- name: Save cache
|
|
||||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
|
||||||
with:
|
with:
|
||||||
path: .
|
name: frontend-dist
|
||||||
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
# Sharded chromium tests
|
# Sharded chromium tests
|
||||||
playwright-tests-chromium-sharded:
|
playwright-tests-chromium-sharded:
|
||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
container:
|
||||||
|
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||||
|
credentials:
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: read
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||||
shardTotal: [8]
|
shardTotal: [8]
|
||||||
steps:
|
steps:
|
||||||
# download built frontend repo from setup job
|
- name: Checkout repository
|
||||||
- name: Wait for cache propagation
|
uses: actions/checkout@v5
|
||||||
run: sleep 10
|
- name: Download built frontend
|
||||||
- name: Restore cached setup
|
uses: actions/download-artifact@v4
|
||||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
|
||||||
with:
|
with:
|
||||||
fail-on-cache-miss: true
|
name: frontend-dist
|
||||||
path: .
|
path: dist/
|
||||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
|
||||||
|
|
||||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
- name: Start ComfyUI server
|
||||||
- name: Setup ComfyUI server
|
uses: ./.github/actions/start-comfyui-server
|
||||||
uses: ./.github/actions/setup-comfyui-server
|
|
||||||
with:
|
|
||||||
launch_server: true
|
|
||||||
- name: Setup nodejs, pnpm, reuse built frontend
|
|
||||||
uses: ./.github/actions/setup-frontend
|
|
||||||
- name: Setup Playwright
|
|
||||||
uses: ./.github/actions/setup-playwright
|
|
||||||
|
|
||||||
# Run sharded tests and upload sharded reports
|
- name: Install frontend deps
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Run sharded tests (browsers pre-installed in container)
|
||||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||||
id: playwright
|
id: playwright
|
||||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||||
@@ -94,39 +84,37 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||||
|
credentials:
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: read
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||||
steps:
|
steps:
|
||||||
# download built frontend repo from setup job
|
- name: Checkout repository
|
||||||
- name: Wait for cache propagation
|
uses: actions/checkout@v5
|
||||||
run: sleep 10
|
- name: Download built frontend
|
||||||
- name: Restore cached setup
|
uses: actions/download-artifact@v4
|
||||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
|
||||||
with:
|
with:
|
||||||
fail-on-cache-miss: true
|
name: frontend-dist
|
||||||
path: .
|
path: dist/
|
||||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
|
||||||
|
|
||||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
- name: Start ComfyUI server
|
||||||
- name: Setup ComfyUI server
|
uses: ./.github/actions/start-comfyui-server
|
||||||
uses: ./.github/actions/setup-comfyui-server
|
|
||||||
with:
|
|
||||||
launch_server: true
|
|
||||||
- name: Setup nodejs, pnpm, reuse built frontend
|
|
||||||
uses: ./.github/actions/setup-frontend
|
|
||||||
- name: Setup Playwright
|
|
||||||
uses: ./.github/actions/setup-playwright
|
|
||||||
|
|
||||||
# Run tests and upload reports
|
- name: Install frontend deps
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Run tests (browsers pre-installed in container)
|
||||||
- name: Run Playwright tests (${{ matrix.browser }})
|
- name: Run Playwright tests (${{ matrix.browser }})
|
||||||
id: playwright
|
id: playwright
|
||||||
run: |
|
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
||||||
# Run tests with blob reporter first
|
|
||||||
pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
|
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||||
|
|
||||||
@@ -147,7 +135,7 @@ jobs:
|
|||||||
path: ./playwright-report/
|
path: ./playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
# Merge sharded test reports
|
# Merge sharded test reports (no container needed - only runs CLI)
|
||||||
merge-reports:
|
merge-reports:
|
||||||
needs: [playwright-tests-chromium-sharded]
|
needs: [playwright-tests-chromium-sharded]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -156,11 +144,9 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
# Setup Test Environment, we only need playwright to merge reports
|
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
|
||||||
- name: Setup frontend
|
- name: Setup frontend
|
||||||
uses: ./.github/actions/setup-frontend
|
uses: ./.github/actions/setup-frontend
|
||||||
- name: Setup Playwright
|
|
||||||
uses: ./.github/actions/setup-playwright
|
|
||||||
|
|
||||||
- name: Download blob reports
|
- name: Download blob reports
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ jobs:
|
|||||||
) &&
|
) &&
|
||||||
startsWith(github.event.comment.body, '/update-playwright') )
|
startsWith(github.event.comment.body, '/update-playwright') )
|
||||||
outputs:
|
outputs:
|
||||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
|
||||||
pr-number: ${{ steps.pr-info.outputs.pr-number }}
|
pr-number: ${{ steps.pr-info.outputs.pr-number }}
|
||||||
branch: ${{ steps.pr-info.outputs.branch }}
|
branch: ${{ steps.pr-info.outputs.branch }}
|
||||||
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
||||||
@@ -64,70 +63,63 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-frontend
|
uses: ./.github/actions/setup-frontend
|
||||||
with:
|
with:
|
||||||
include_build_step: true
|
include_build_step: true
|
||||||
# Save expensive build artifacts (Python env, built frontend, node_modules)
|
|
||||||
# Source code will be checked out fresh in sharded jobs
|
# Upload built dist/ (containerized test jobs will pnpm install without cache)
|
||||||
- name: Generate cache key
|
- name: Upload built frontend
|
||||||
id: cache-key
|
uses: actions/upload-artifact@v4
|
||||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
|
||||||
- name: Save cache
|
|
||||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
|
||||||
with:
|
with:
|
||||||
path: |
|
name: frontend-dist
|
||||||
ComfyUI
|
path: dist/
|
||||||
dist
|
retention-days: 1
|
||||||
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
|
|
||||||
|
|
||||||
# Sharded snapshot updates
|
# Sharded snapshot updates
|
||||||
update-snapshots-sharded:
|
update-snapshots-sharded:
|
||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||||
|
credentials:
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shardIndex: [1, 2, 3, 4]
|
shardIndex: [1, 2, 3, 4]
|
||||||
shardTotal: [4]
|
shardTotal: [4]
|
||||||
steps:
|
steps:
|
||||||
# Checkout source code fresh (not cached)
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.setup.outputs.branch }}
|
ref: ${{ needs.setup.outputs.branch }}
|
||||||
|
- name: Download built frontend
|
||||||
# Restore expensive build artifacts from setup job
|
uses: actions/download-artifact@v4
|
||||||
- name: Restore cached artifacts
|
|
||||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
|
||||||
with:
|
with:
|
||||||
fail-on-cache-miss: true
|
name: frontend-dist
|
||||||
path: |
|
path: dist/
|
||||||
ComfyUI
|
|
||||||
dist
|
|
||||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
|
||||||
|
|
||||||
- name: Setup ComfyUI server (from cache)
|
- name: Start ComfyUI server
|
||||||
uses: ./.github/actions/setup-comfyui-server
|
uses: ./.github/actions/start-comfyui-server
|
||||||
with:
|
|
||||||
launch_server: true
|
|
||||||
|
|
||||||
- name: Setup nodejs, pnpm, reuse built frontend
|
- name: Install frontend deps
|
||||||
uses: ./.github/actions/setup-frontend
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Setup Playwright
|
# Run sharded tests with snapshot updates (browsers pre-installed in container)
|
||||||
uses: ./.github/actions/setup-playwright
|
|
||||||
|
|
||||||
# Run sharded tests with snapshot updates
|
|
||||||
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||||
id: playwright-tests
|
id: playwright-tests
|
||||||
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# Identify and stage only changed snapshot files
|
|
||||||
- name: Stage changed snapshot files
|
- name: Stage changed snapshot files
|
||||||
id: changed-snapshots
|
id: changed-snapshots
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "=========================================="
|
|
||||||
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
|
# Configure git safe.directory for container environment
|
||||||
echo "=========================================="
|
git config --global --add safe.directory "$(pwd)"
|
||||||
|
|
||||||
# Get list of changed snapshot files (including untracked/new files)
|
# Get list of changed snapshot files (including untracked/new files)
|
||||||
changed_files=$( (
|
changed_files=$( (
|
||||||
@@ -136,43 +128,25 @@ jobs:
|
|||||||
) | sort -u | grep -E '\-snapshots/' || true )
|
) | sort -u | grep -E '\-snapshots/' || true )
|
||||||
|
|
||||||
if [ -z "$changed_files" ]; then
|
if [ -z "$changed_files" ]; then
|
||||||
echo "No snapshot changes in this shard"
|
echo "No snapshot changes in shard ${{ matrix.shardIndex }}"
|
||||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✓ Found changed files:"
|
|
||||||
echo "$changed_files"
|
|
||||||
file_count=$(echo "$changed_files" | wc -l)
|
file_count=$(echo "$changed_files" | wc -l)
|
||||||
echo "Count: $file_count"
|
echo "Shard ${{ matrix.shardIndex }}: $file_count changed snapshot(s):"
|
||||||
|
echo "$changed_files"
|
||||||
echo "has-changes=true" >> $GITHUB_OUTPUT
|
echo "has-changes=true" >> $GITHUB_OUTPUT
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create staging directory
|
# Copy changed files to staging directory
|
||||||
mkdir -p /tmp/changed_snapshots_shard
|
mkdir -p /tmp/changed_snapshots_shard
|
||||||
|
|
||||||
# Copy only changed files, preserving directory structure
|
|
||||||
# Strip 'browser_tests/' prefix to avoid double nesting
|
|
||||||
echo "Copying changed files to staging directory..."
|
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
# Skip paths that no longer exist (e.g. deletions)
|
[ -f "$file" ] || continue
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
echo " → (skipped; not a file) $file"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# Remove 'browser_tests/' prefix
|
|
||||||
file_without_prefix="${file#browser_tests/}"
|
file_without_prefix="${file#browser_tests/}"
|
||||||
# Create parent directories
|
|
||||||
mkdir -p "/tmp/changed_snapshots_shard/$(dirname "$file_without_prefix")"
|
mkdir -p "/tmp/changed_snapshots_shard/$(dirname "$file_without_prefix")"
|
||||||
# Copy file
|
|
||||||
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
|
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
|
||||||
echo " → $file_without_prefix"
|
|
||||||
done <<< "$changed_files"
|
done <<< "$changed_files"
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Staged files for upload:"
|
|
||||||
find /tmp/changed_snapshots_shard -type f
|
|
||||||
|
|
||||||
# Upload ONLY the changed files from this shard
|
# Upload ONLY the changed files from this shard
|
||||||
- name: Upload changed snapshots
|
- name: Upload changed snapshots
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -213,9 +187,15 @@ jobs:
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "DOWNLOADED SNAPSHOT FILES"
|
echo "DOWNLOADED SNAPSHOT FILES"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
find ./downloaded-snapshots -type f
|
if [ -d "./downloaded-snapshots" ]; then
|
||||||
echo ""
|
find ./downloaded-snapshots -type f
|
||||||
echo "Total files: $(find ./downloaded-snapshots -type f | wc -l)"
|
echo ""
|
||||||
|
echo "Total files: $(find ./downloaded-snapshots -type f | wc -l)"
|
||||||
|
else
|
||||||
|
echo "No snapshot artifacts downloaded (no changes in any shard)"
|
||||||
|
echo ""
|
||||||
|
echo "Total files: 0"
|
||||||
|
fi
|
||||||
|
|
||||||
# Merge only the changed files into browser_tests
|
# Merge only the changed files into browser_tests
|
||||||
- name: Merge changed snapshots
|
- name: Merge changed snapshots
|
||||||
@@ -226,6 +206,16 @@ jobs:
|
|||||||
echo "MERGING CHANGED SNAPSHOTS"
|
echo "MERGING CHANGED SNAPSHOTS"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Check if any artifacts were downloaded
|
||||||
|
if [ ! -d "./downloaded-snapshots" ]; then
|
||||||
|
echo "No snapshot artifacts to merge"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "MERGE COMPLETE"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Shards merged: 0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Verify target directory exists
|
# Verify target directory exists
|
||||||
if [ ! -d "browser_tests" ]; then
|
if [ ! -d "browser_tests" ]; then
|
||||||
echo "::error::Target directory 'browser_tests' does not exist"
|
echo "::error::Target directory 'browser_tests' does not exist"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { defineConfig } = require('@lobehub/i18n-cli');
|
|||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
splitToken: 1024,
|
splitToken: 1024,
|
||||||
|
saveImmediately: true,
|
||||||
entry: 'src/locales/en',
|
entry: 'src/locales/en',
|
||||||
entryLocale: 'en',
|
entryLocale: 'en',
|
||||||
output: 'src/locales',
|
output: 'src/locales',
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 91 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.36.12",
|
"version": "1.36.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="container"
|
ref="container"
|
||||||
class="relative h-full w-full"
|
class="relative h-full w-full min-h-[200px]"
|
||||||
data-capture-wheel="true"
|
data-capture-wheel="true"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@pointermove.stop
|
@pointermove.stop
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
|||||||
import { cva } from 'cva'
|
import { cva } from 'cva'
|
||||||
|
|
||||||
export const buttonVariants = cva({
|
export const buttonVariants = cva({
|
||||||
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
|
||||||
export interface WidgetSlotMetadata {
|
export interface WidgetSlotMetadata {
|
||||||
index: number
|
index: number
|
||||||
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
|
|||||||
borderStyle?: string
|
borderStyle?: string
|
||||||
callback?: ((value: unknown) => void) | undefined
|
callback?: ((value: unknown) => void) | undefined
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
|
hasLayoutSize?: boolean
|
||||||
isDOMWidget?: boolean
|
isDOMWidget?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
@@ -73,6 +75,7 @@ export interface VueNodeData {
|
|||||||
hasErrors?: boolean
|
hasErrors?: boolean
|
||||||
inputs?: INodeInputSlot[]
|
inputs?: INodeInputSlot[]
|
||||||
outputs?: INodeOutputSlot[]
|
outputs?: INodeOutputSlot[]
|
||||||
|
resizable?: boolean
|
||||||
shape?: number
|
shape?: number
|
||||||
subgraphId?: string | null
|
subgraphId?: string | null
|
||||||
titleMode?: TitleMode
|
titleMode?: TitleMode
|
||||||
@@ -171,7 +174,12 @@ export function safeWidgetMapper(
|
|||||||
const callback = (v: unknown) => {
|
const callback = (v: unknown) => {
|
||||||
const value = normalizeWidgetValue(v)
|
const value = normalizeWidgetValue(v)
|
||||||
widget.value = value ?? undefined
|
widget.value = value ?? undefined
|
||||||
widget.callback?.(value)
|
// Match litegraph callback signature: (value, canvas, node, pos, event)
|
||||||
|
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
|
||||||
|
widget.callback?.(value, app.canvas, node)
|
||||||
|
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
|
||||||
|
// This ensures widgets that depend on other widget values get updated
|
||||||
|
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +189,7 @@ export function safeWidgetMapper(
|
|||||||
borderStyle,
|
borderStyle,
|
||||||
callback,
|
callback,
|
||||||
controlWidget: getControlWidget(widget),
|
controlWidget: getControlWidget(widget),
|
||||||
|
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||||
isDOMWidget: isDOMWidget(widget),
|
isDOMWidget: isDOMWidget(widget),
|
||||||
label: widget.label,
|
label: widget.label,
|
||||||
nodeType: getNodeType(node, widget),
|
nodeType: getNodeType(node, widget),
|
||||||
@@ -236,17 +245,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
for (const widget of currentData.widgets ?? []) {
|
||||||
const slotInfo = slotMetadata.get(widget.name)
|
const slotInfo = slotMetadata.get(widget.name)
|
||||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
if (slotInfo) widget.slotMetadata = slotInfo
|
||||||
})
|
}
|
||||||
|
|
||||||
vueNodeData.set(nodeId, {
|
|
||||||
...currentData,
|
|
||||||
widgets: updatedWidgets,
|
|
||||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
|
||||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract safe data from LiteGraph node for Vue consumption
|
// Extract safe data from LiteGraph node for Vue consumption
|
||||||
@@ -317,6 +319,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
|||||||
flags: node.flags ? { ...node.flags } : undefined,
|
flags: node.flags ? { ...node.flags } : undefined,
|
||||||
color: node.color || undefined,
|
color: node.color || undefined,
|
||||||
bgcolor: node.bgcolor || undefined,
|
bgcolor: node.bgcolor || undefined,
|
||||||
|
resizable: node.resizable,
|
||||||
shape: node.shape
|
shape: node.shape
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export function useMaskEditorLoader() {
|
|||||||
// If we have a widget filename, we should prioritize it over the node image
|
// If we have a widget filename, we should prioritize it over the node image
|
||||||
// because the node image might be stale (e.g. from a previous save)
|
// because the node image might be stale (e.g. from a previous save)
|
||||||
// while the widget value reflects the current selection.
|
// while the widget value reflects the current selection.
|
||||||
if (widgetFilename) {
|
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
|
||||||
|
if (widgetFilename && !widgetFilename.startsWith('$')) {
|
||||||
try {
|
try {
|
||||||
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
|
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
|
||||||
let filename = widgetFilename
|
let filename = widgetFilename
|
||||||
|
|||||||
@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
|
|
||||||
const model = String(modelWidget.value)
|
const model = String(modelWidget.value)
|
||||||
|
|
||||||
// Google Veo video generation
|
if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||||
if (model.includes('veo-2.0')) {
|
|
||||||
return formatCreditsLabel(0.5, { suffix: '/second' })
|
|
||||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
|
||||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gemini-2.5-flash')) {
|
} else if (model.includes('gemini-2.5-flash')) {
|
||||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||||
return formatCreditsListLabel([0.00125, 0.01], {
|
return formatCreditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gemini-2.5-pro')) {
|
} else if (model.includes('gemini-2.5-pro')) {
|
||||||
return formatCreditsListLabel([0.00125, 0.01], {
|
return formatCreditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gemini-3-pro-preview')) {
|
} else if (model.includes('gemini-3-pro-preview')) {
|
||||||
return formatCreditsListLabel([0.002, 0.012], {
|
return formatCreditsListLabel([0.002, 0.012], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// For other Gemini models, show token-based pricing info
|
// For other Gemini models, show token-based pricing info
|
||||||
@@ -1899,51 +1906,75 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||||
if (model.includes('o4-mini')) {
|
if (model.includes('o4-mini')) {
|
||||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('o1-pro')) {
|
} else if (model.includes('o1-pro')) {
|
||||||
return formatCreditsListLabel([0.15, 0.6], {
|
return formatCreditsListLabel([0.15, 0.6], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('o1')) {
|
} else if (model.includes('o1')) {
|
||||||
return formatCreditsListLabel([0.015, 0.06], {
|
return formatCreditsListLabel([0.015, 0.06], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('o3-mini')) {
|
} else if (model.includes('o3-mini')) {
|
||||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('o3')) {
|
} else if (model.includes('o3')) {
|
||||||
return formatCreditsListLabel([0.01, 0.04], {
|
return formatCreditsListLabel([0.01, 0.04], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-4o')) {
|
} else if (model.includes('gpt-4o')) {
|
||||||
return formatCreditsListLabel([0.0025, 0.01], {
|
return formatCreditsListLabel([0.0025, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-4.1-nano')) {
|
} else if (model.includes('gpt-4.1-nano')) {
|
||||||
return formatCreditsListLabel([0.0001, 0.0004], {
|
return formatCreditsListLabel([0.0001, 0.0004], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-4.1-mini')) {
|
} else if (model.includes('gpt-4.1-mini')) {
|
||||||
return formatCreditsListLabel([0.0004, 0.0016], {
|
return formatCreditsListLabel([0.0004, 0.0016], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-4.1')) {
|
} else if (model.includes('gpt-4.1')) {
|
||||||
return formatCreditsListLabel([0.002, 0.008], {
|
return formatCreditsListLabel([0.002, 0.008], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-5-nano')) {
|
} else if (model.includes('gpt-5-nano')) {
|
||||||
return formatCreditsListLabel([0.00005, 0.0004], {
|
return formatCreditsListLabel([0.00005, 0.0004], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-5-mini')) {
|
} else if (model.includes('gpt-5-mini')) {
|
||||||
return formatCreditsListLabel([0.00025, 0.002], {
|
return formatCreditsListLabel([0.00025, 0.002], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
} else if (model.includes('gpt-5')) {
|
} else if (model.includes('gpt-5')) {
|
||||||
return formatCreditsListLabel([0.00125, 0.01], {
|
return formatCreditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return 'Token-based'
|
return 'Token-based'
|
||||||
@@ -2101,6 +2132,267 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
},
|
},
|
||||||
LtxvApiImageToVideo: {
|
LtxvApiImageToVideo: {
|
||||||
displayPrice: ltxvPricingCalculator
|
displayPrice: ltxvPricingCalculator
|
||||||
|
},
|
||||||
|
WanReferenceVideoApi: {
|
||||||
|
displayPrice: (node: LGraphNode): string => {
|
||||||
|
const durationWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'duration'
|
||||||
|
) as IComboWidget
|
||||||
|
const sizeWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'size'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!durationWidget || !sizeWidget) {
|
||||||
|
return formatCreditsRangeLabel(0.7, 1.5, {
|
||||||
|
note: '(varies with size & duration)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = parseFloat(String(durationWidget.value))
|
||||||
|
const sizeStr = String(sizeWidget.value).toLowerCase()
|
||||||
|
|
||||||
|
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
|
||||||
|
const inputMin = 2 * rate
|
||||||
|
const inputMax = 5 * rate
|
||||||
|
const outputPrice = seconds * rate
|
||||||
|
|
||||||
|
const minTotal = inputMin + outputPrice
|
||||||
|
const maxTotal = inputMax + outputPrice
|
||||||
|
|
||||||
|
return formatCreditsRangeLabel(minTotal, maxTotal)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Vidu2TextToVideoNode: {
|
||||||
|
displayPrice: (node: LGraphNode): string => {
|
||||||
|
const durationWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'duration'
|
||||||
|
) as IComboWidget
|
||||||
|
const resolutionWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'resolution'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!durationWidget || !resolutionWidget) {
|
||||||
|
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||||
|
note: '(varies with duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = parseFloat(String(durationWidget.value))
|
||||||
|
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||||
|
|
||||||
|
// Text-to-Video uses Q2 model only
|
||||||
|
// 720P: Starts at $0.075, +$0.025/sec
|
||||||
|
// 1080P: Starts at $0.10, +$0.05/sec
|
||||||
|
let basePrice: number
|
||||||
|
let pricePerSecond: number
|
||||||
|
|
||||||
|
if (resolution.includes('1080')) {
|
||||||
|
basePrice = 0.1
|
||||||
|
pricePerSecond = 0.05
|
||||||
|
} else {
|
||||||
|
// 720P default
|
||||||
|
basePrice = 0.075
|
||||||
|
pricePerSecond = 0.025
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||||
|
note: '(varies with duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Vidu2ImageToVideoNode: {
|
||||||
|
displayPrice: (node: LGraphNode): string => {
|
||||||
|
const modelWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'model'
|
||||||
|
) as IComboWidget
|
||||||
|
const durationWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'duration'
|
||||||
|
) as IComboWidget
|
||||||
|
const resolutionWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'resolution'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||||
|
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||||
|
note: '(varies with model, duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = String(modelWidget.value).toLowerCase()
|
||||||
|
const duration = parseFloat(String(durationWidget.value))
|
||||||
|
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||||
|
const is1080p = resolution.includes('1080')
|
||||||
|
|
||||||
|
let basePrice: number
|
||||||
|
let pricePerSecond: number
|
||||||
|
|
||||||
|
if (model.includes('q2-pro-fast')) {
|
||||||
|
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||||
|
basePrice = is1080p ? 0.08 : 0.04
|
||||||
|
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||||
|
} else if (model.includes('q2-pro')) {
|
||||||
|
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||||
|
basePrice = is1080p ? 0.275 : 0.075
|
||||||
|
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||||
|
} else if (model.includes('q2-turbo')) {
|
||||||
|
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||||
|
if (is1080p) {
|
||||||
|
basePrice = 0.175
|
||||||
|
pricePerSecond = 0.05
|
||||||
|
} else {
|
||||||
|
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||||
|
if (duration <= 1) {
|
||||||
|
return formatCreditsLabel(0.04)
|
||||||
|
}
|
||||||
|
if (duration <= 2) {
|
||||||
|
return formatCreditsLabel(0.05)
|
||||||
|
}
|
||||||
|
const cost = 0.05 + 0.05 * (duration - 2)
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||||
|
note: '(varies with model, duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||||
|
note: '(varies with model, duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Vidu2ReferenceVideoNode: {
|
||||||
|
displayPrice: (node: LGraphNode): string => {
|
||||||
|
const durationWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'duration'
|
||||||
|
) as IComboWidget
|
||||||
|
const resolutionWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'resolution'
|
||||||
|
) as IComboWidget
|
||||||
|
const audioWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'audio'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!durationWidget) {
|
||||||
|
return formatCreditsRangeLabel(0.125, 1.5, {
|
||||||
|
note: '(varies with duration, resolution & audio)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = parseFloat(String(durationWidget.value))
|
||||||
|
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
|
||||||
|
const is1080p = resolution.includes('1080')
|
||||||
|
|
||||||
|
// Check if audio is enabled (adds $0.75)
|
||||||
|
const audioValue = audioWidget?.value
|
||||||
|
const hasAudio =
|
||||||
|
audioValue !== undefined &&
|
||||||
|
audioValue !== null &&
|
||||||
|
String(audioValue).toLowerCase() !== 'false' &&
|
||||||
|
String(audioValue).toLowerCase() !== 'none' &&
|
||||||
|
audioValue !== ''
|
||||||
|
|
||||||
|
// Reference-to-Video uses Q2 model
|
||||||
|
// 720P: Starts at $0.125, +$0.025/sec
|
||||||
|
// 1080P: Starts at $0.375, +$0.05/sec
|
||||||
|
let basePrice: number
|
||||||
|
let pricePerSecond: number
|
||||||
|
|
||||||
|
if (is1080p) {
|
||||||
|
basePrice = 0.375
|
||||||
|
pricePerSecond = 0.05
|
||||||
|
} else {
|
||||||
|
// 720P default
|
||||||
|
basePrice = 0.125
|
||||||
|
pricePerSecond = 0.025
|
||||||
|
}
|
||||||
|
|
||||||
|
let cost = basePrice
|
||||||
|
if (Number.isFinite(duration) && duration > 0) {
|
||||||
|
cost = basePrice + pricePerSecond * (duration - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio adds $0.75 on top
|
||||||
|
if (hasAudio) {
|
||||||
|
cost += 0.075
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Vidu2StartEndToVideoNode: {
|
||||||
|
displayPrice: (node: LGraphNode): string => {
|
||||||
|
const modelWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'model'
|
||||||
|
) as IComboWidget
|
||||||
|
const durationWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'duration'
|
||||||
|
) as IComboWidget
|
||||||
|
const resolutionWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'resolution'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||||
|
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||||
|
note: '(varies with model, duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = String(modelWidget.value).toLowerCase()
|
||||||
|
const duration = parseFloat(String(durationWidget.value))
|
||||||
|
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||||
|
const is1080p = resolution.includes('1080')
|
||||||
|
|
||||||
|
let basePrice: number
|
||||||
|
let pricePerSecond: number
|
||||||
|
|
||||||
|
if (model.includes('q2-pro-fast')) {
|
||||||
|
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||||
|
basePrice = is1080p ? 0.08 : 0.04
|
||||||
|
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||||
|
} else if (model.includes('q2-pro')) {
|
||||||
|
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||||
|
basePrice = is1080p ? 0.275 : 0.075
|
||||||
|
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||||
|
} else if (model.includes('q2-turbo')) {
|
||||||
|
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||||
|
if (is1080p) {
|
||||||
|
basePrice = 0.175
|
||||||
|
pricePerSecond = 0.05
|
||||||
|
} else {
|
||||||
|
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||||
|
if (!Number.isFinite(duration) || duration <= 1) {
|
||||||
|
return formatCreditsLabel(0.04)
|
||||||
|
}
|
||||||
|
if (duration <= 2) {
|
||||||
|
return formatCreditsLabel(0.05)
|
||||||
|
}
|
||||||
|
const cost = 0.05 + 0.05 * (duration - 2)
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||||
|
note: '(varies with model, duration & resolution)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return formatCreditsLabel(basePrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||||
|
return formatCreditsLabel(cost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2254,8 +2546,13 @@ export const useNodePricing = () => {
|
|||||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||||
WanTextToVideoApi: ['duration', 'size'],
|
WanTextToVideoApi: ['duration', 'size'],
|
||||||
WanImageToVideoApi: ['duration', 'resolution'],
|
WanImageToVideoApi: ['duration', 'resolution'],
|
||||||
|
WanReferenceVideoApi: ['duration', 'size'],
|
||||||
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
||||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
|
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
|
||||||
|
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
|
||||||
|
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||||
|
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
|
||||||
|
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
|
||||||
}
|
}
|
||||||
return widgetMap[nodeType] || []
|
return widgetMap[nodeType] || []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function dynamicComboWidget(
|
|||||||
const newSpec = value ? options[value] : undefined
|
const newSpec = value ? options[value] : undefined
|
||||||
|
|
||||||
const removedInputs = remove(node.inputs, isInGroup)
|
const removedInputs = remove(node.inputs, isInGroup)
|
||||||
remove(node.widgets, isInGroup)
|
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||||
|
|
||||||
if (!newSpec) return
|
if (!newSpec) return
|
||||||
|
|
||||||
@@ -341,10 +341,16 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
|||||||
//TODO: instead apply on output add?
|
//TODO: instead apply on output add?
|
||||||
//ensure outputs get updated
|
//ensure outputs get updated
|
||||||
const index = node.inputs.length - 1
|
const index = node.inputs.length - 1
|
||||||
const input = node.inputs.at(-1)!
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() =>
|
const input = node.inputs.at(index)!
|
||||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
node.onConnectionsChange?.(
|
||||||
)
|
LiteGraph.INPUT,
|
||||||
|
index,
|
||||||
|
!!input.link,
|
||||||
|
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||||
|
input
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function autogrowOrdinalToName(
|
function autogrowOrdinalToName(
|
||||||
@@ -482,7 +488,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
|||||||
for (const input of toRemove) {
|
for (const input of toRemove) {
|
||||||
const widgetName = input?.widget?.name
|
const widgetName = input?.widget?.name
|
||||||
if (!widgetName) continue
|
if (!widgetName) continue
|
||||||
remove(node.widgets, (w) => w.name === widgetName)
|
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||||
|
widget.onRemove?.()
|
||||||
}
|
}
|
||||||
node.size[1] = node.computeSize([...node.size])[1]
|
node.size[1] = node.computeSize([...node.size])[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,8 @@ class Load3d {
|
|||||||
this.STATUS_MOUSE_ON_SCENE ||
|
this.STATUS_MOUSE_ON_SCENE ||
|
||||||
this.STATUS_MOUSE_ON_VIEWER ||
|
this.STATUS_MOUSE_ON_VIEWER ||
|
||||||
this.isRecording() ||
|
this.isRecording() ||
|
||||||
!this.INITIAL_RENDER_DONE
|
!this.INITIAL_RENDER_DONE ||
|
||||||
|
this.animationManager.isAnimationPlaying
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
|||||||
fbxModel.traverse((child) => {
|
fbxModel.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh) {
|
||||||
this.modelManager.originalMaterials.set(child, child.material)
|
this.modelManager.originalMaterials.set(child, child.material)
|
||||||
|
|
||||||
|
if (child instanceof THREE.SkinnedMesh) {
|
||||||
|
child.frustumCulled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
@@ -203,6 +207,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
|||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh) {
|
||||||
child.geometry.computeVertexNormals()
|
child.geometry.computeVertexNormals()
|
||||||
this.modelManager.originalMaterials.set(child, child.material)
|
this.modelManager.originalMaterials.set(child, child.material)
|
||||||
|
|
||||||
|
if (child instanceof THREE.SkinnedMesh) {
|
||||||
|
child.frustumCulled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import _ from 'es-toolkit/compat'
|
import _ from 'es-toolkit/compat'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
|
||||||
import { app } from '@/scripts/app'
|
import { app, ComfyApp } from '@/scripts/app'
|
||||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||||
@@ -20,6 +20,18 @@ function openMaskEditor(node: LGraphNode): void {
|
|||||||
useMaskEditor().openMaskEditor(node)
|
useMaskEditor().openMaskEditor(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open mask editor from clipspace (for plugin compatibility)
|
||||||
|
// This is called when ComfyApp.open_maskeditor() is invoked without arguments
|
||||||
|
function openMaskEditorFromClipspace(): void {
|
||||||
|
const node = ComfyApp.clipspace_return_node as LGraphNode | null
|
||||||
|
if (!node) {
|
||||||
|
console.error('[MaskEditor] No clipspace_return_node found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openMaskEditor(node)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the dialog is already opened
|
// Check if the dialog is already opened
|
||||||
function isOpened(): boolean {
|
function isOpened(): boolean {
|
||||||
return useDialogStore().isDialogOpen('global-mask-editor')
|
return useDialogStore().isDialogOpen('global-mask-editor')
|
||||||
@@ -78,7 +90,16 @@ app.registerExtension({
|
|||||||
label: 'Decrease Brush Size in MaskEditor',
|
label: 'Decrease Brush Size in MaskEditor',
|
||||||
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
|
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
init() {
|
||||||
|
// Set up ComfyApp static methods for plugin compatibility (deprecated)
|
||||||
|
ComfyApp.open_maskeditor = openMaskEditorFromClipspace
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'[MaskEditor] ComfyApp.open_maskeditor is deprecated. ' +
|
||||||
|
'Plugins should migrate to using the command system or direct node context menu integration.'
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
|
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
} from '@/lib/litegraph/src/types/serialisation'
|
} from '@/lib/litegraph/src/types/serialisation'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||||
|
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
|
||||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||||
|
|
||||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||||
@@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
|||||||
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
|
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
|
||||||
this
|
this
|
||||||
)
|
)
|
||||||
|
if (widget instanceof AssetWidget)
|
||||||
|
promotedWidget.options.nodeType ??= widget.node.type
|
||||||
|
|
||||||
Object.assign(promotedWidget, {
|
Object.assign(promotedWidget, {
|
||||||
get name() {
|
get name() {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
|
|||||||
socketless?: boolean
|
socketless?: boolean
|
||||||
/** If `true`, the widget will not be rendered by the Vue renderer. */
|
/** If `true`, the widget will not be rendered by the Vue renderer. */
|
||||||
canvasOnly?: boolean
|
canvasOnly?: boolean
|
||||||
|
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||||
|
nodeType?: string
|
||||||
|
|
||||||
values?: TValues
|
values?: TValues
|
||||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
id: 'Comfy.Validation.Workflows',
|
id: 'Comfy.Validation.Workflows',
|
||||||
name: 'Validate workflows',
|
name: 'Validate workflows',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: isCloud ? false : true
|
defaultValue: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Comfy.NodeSearchBoxImpl',
|
id: 'Comfy.NodeSearchBoxImpl',
|
||||||
|
|||||||
@@ -99,7 +99,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Multiple Images Navigation -->
|
<!-- Multiple Images Navigation -->
|
||||||
<div v-if="hasMultipleImages" class="flex justify-center gap-1 pt-4">
|
<div
|
||||||
|
v-if="hasMultipleImages"
|
||||||
|
class="flex flex-wrap justify-center gap-1 pt-4"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="(_, index) in imageUrls"
|
v-for="(_, index) in imageUrls"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
|
|
||||||
<!-- Resize handle (bottom-right only) -->
|
<!-- Resize handle (bottom-right only) -->
|
||||||
<div
|
<div
|
||||||
v-if="!isCollapsed"
|
v-if="!isCollapsed && nodeData.resizable !== false"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="t('g.resizeFromBottomRight')"
|
:aria-label="t('g.resizeFromBottomRight')"
|
||||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||||
@@ -341,6 +341,7 @@ const handleResizePointerDown = (event: PointerEvent) => {
|
|||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
if (!shouldHandleNodePointerEvents.value) return
|
if (!shouldHandleNodePointerEvents.value) return
|
||||||
if (nodeData.flags?.pinned) return
|
if (nodeData.flags?.pinned) return
|
||||||
|
if (nodeData.resizable === false) return
|
||||||
startResize(event)
|
startResize(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,10 +203,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const gridTemplateRows = computed((): string => {
|
const gridTemplateRows = computed((): string => {
|
||||||
const widgets = toValue(processedWidgets)
|
if (!nodeData?.widgets) return ''
|
||||||
return widgets
|
const processedNames = new Set(toValue(processedWidgets).map((w) => w.name))
|
||||||
.filter((w) => !w.simplified.options?.hidden)
|
return nodeData.widgets
|
||||||
.map((w) => (shouldExpand(w.type) ? 'auto' : 'min-content'))
|
.filter((w) => processedNames.has(w.name) && !w.options?.hidden)
|
||||||
|
.map((w) =>
|
||||||
|
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||||
|
)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,10 +31,17 @@ const precision = computed(() => {
|
|||||||
|
|
||||||
// Calculate the step value based on precision or widget options
|
// Calculate the step value based on precision or widget options
|
||||||
const stepValue = computed(() => {
|
const stepValue = computed(() => {
|
||||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
// Use step2 (correct input spec value) if available
|
||||||
if (props.widget.options?.step2 !== undefined) {
|
if (props.widget.options?.step2 !== undefined) {
|
||||||
return Number(props.widget.options.step2)
|
return Number(props.widget.options.step2)
|
||||||
}
|
}
|
||||||
|
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
||||||
|
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
||||||
|
// We skip default step values (1, 10) to avoid affecting normal widgets
|
||||||
|
const step = props.widget.options?.step
|
||||||
|
if (step !== undefined && step > 10) {
|
||||||
|
return Number(step) / 10
|
||||||
|
}
|
||||||
// Otherwise, derive from precision
|
// Otherwise, derive from precision
|
||||||
if (precision.value !== undefined) {
|
if (precision.value !== undefined) {
|
||||||
if (precision.value === 0) {
|
if (precision.value === 0) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const canvasEl = ref()
|
const canvasEl = ref()
|
||||||
|
const containerHeight = ref(20)
|
||||||
|
|
||||||
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
|
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
|
||||||
let node: LGraphNode | undefined
|
let node: LGraphNode | undefined
|
||||||
@@ -52,9 +53,19 @@ onBeforeUnmount(() => {
|
|||||||
function draw() {
|
function draw() {
|
||||||
if (!widgetInstance || !node) return
|
if (!widgetInstance || !node) return
|
||||||
const width = canvasEl.value.parentElement.clientWidth
|
const width = canvasEl.value.parentElement.clientWidth
|
||||||
const height = widgetInstance.computeSize
|
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
|
||||||
? widgetInstance.computeSize(width)[1]
|
let height = 20
|
||||||
: 20
|
if (widgetInstance.computedHeight) {
|
||||||
|
height = widgetInstance.computedHeight
|
||||||
|
} else if (widgetInstance.computeLayoutSize) {
|
||||||
|
height = widgetInstance.computeLayoutSize(node).minHeight
|
||||||
|
} else if (widgetInstance.computeSize) {
|
||||||
|
height = widgetInstance.computeSize(width)[1]
|
||||||
|
}
|
||||||
|
containerHeight.value = height
|
||||||
|
// Set node.canvasHeight for legacy widgets that use it (e.g., Impact Pack)
|
||||||
|
// @ts-expect-error canvasHeight is a custom property used by some extensions
|
||||||
|
node.canvasHeight = height
|
||||||
widgetInstance.y = 0
|
widgetInstance.y = 0
|
||||||
canvasEl.value.height = (height + 2) * scaleFactor
|
canvasEl.value.height = (height + 2) * scaleFactor
|
||||||
canvasEl.value.width = width * scaleFactor
|
canvasEl.value.width = width * scaleFactor
|
||||||
@@ -87,10 +98,13 @@ function handleMove(e: PointerEvent) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="relative mx-[-12px] min-w-0 basis-0">
|
<div
|
||||||
|
class="relative mx-[-12px] min-w-0 basis-0"
|
||||||
|
:style="{ minHeight: `${containerHeight}px` }"
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref="canvasEl"
|
ref="canvasEl"
|
||||||
class="absolute mt-[-13px] w-full cursor-crosshair"
|
class="absolute w-full cursor-crosshair"
|
||||||
@pointerdown="handleDown"
|
@pointerdown="handleDown"
|
||||||
@pointerup="handleUp"
|
@pointerup="handleUp"
|
||||||
@pointermove="handleMove"
|
@pointermove="handleMove"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
/>
|
/>
|
||||||
<WidgetWithControl
|
<WidgetWithControl
|
||||||
v-else-if="widget.controlWidget"
|
v-else-if="widget.controlWidget"
|
||||||
|
v-model="modelValue"
|
||||||
:component="WidgetSelectDefault"
|
:component="WidgetSelectDefault"
|
||||||
:widget="widget as StringControlWidget"
|
:widget="widget as StringControlWidget"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ const combinedProps = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const getAssetData = () => {
|
const getAssetData = () => {
|
||||||
if (props.isAssetMode && props.nodeType) {
|
const nodeType = props.widget.options?.nodeType ?? props.nodeType
|
||||||
return useAssetWidgetData(toRef(() => props.nodeType))
|
if (props.isAssetMode && nodeType) {
|
||||||
|
return useAssetWidgetData(toRef(nodeType))
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,7 +368,8 @@ export const useImagePreviewWidget = () => {
|
|||||||
) => {
|
) => {
|
||||||
return node.addCustomWidget(
|
return node.addCustomWidget(
|
||||||
new ImagePreviewWidget(node, inputSpec.name, {
|
new ImagePreviewWidget(node, inputSpec.name, {
|
||||||
serialize: false
|
serialize: false,
|
||||||
|
canvasOnly: true
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, toValue } from 'vue'
|
|||||||
import type { MaybeRefOrGetter } from 'vue'
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
interface NumberWidgetOptions {
|
interface NumberWidgetOptions {
|
||||||
|
step?: number
|
||||||
step2?: number
|
step2?: number
|
||||||
precision?: number
|
precision?: number
|
||||||
}
|
}
|
||||||
@@ -17,10 +18,17 @@ export function useNumberStepCalculation(
|
|||||||
) {
|
) {
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
const precision = toValue(precisionArg)
|
const precision = toValue(precisionArg)
|
||||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
// Use step2 (correct input spec value) if available
|
||||||
if (options?.step2 !== undefined) {
|
if (options?.step2 !== undefined) {
|
||||||
return Number(options.step2)
|
return Number(options.step2)
|
||||||
}
|
}
|
||||||
|
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
||||||
|
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
||||||
|
// We skip default step values (1, 10) to avoid affecting normal widgets
|
||||||
|
const step = options?.step
|
||||||
|
if (step !== undefined && step > 10) {
|
||||||
|
return Number(step) / 10
|
||||||
|
}
|
||||||
|
|
||||||
if (precision === undefined) {
|
if (precision === undefined) {
|
||||||
return returnUndefinedForDefault ? undefined : 0
|
return returnUndefinedForDefault ? undefined : 0
|
||||||
@@ -29,7 +37,9 @@ export function useNumberStepCalculation(
|
|||||||
if (precision === 0) return 1
|
if (precision === 0) return 1
|
||||||
|
|
||||||
// For precision > 0, step = 1 / (10^precision)
|
// For precision > 0, step = 1 / (10^precision)
|
||||||
const step = 1 / Math.pow(10, precision)
|
const calculatedStep = 1 / Math.pow(10, precision)
|
||||||
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
|
return returnUndefinedForDefault
|
||||||
|
? calculatedStep
|
||||||
|
: Number(calculatedStep.toFixed(precision))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
49
tests-ui/tests/composables/graph/useGraphNodeManager.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||||
|
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
|
|
||||||
|
setActivePinia(createTestingPinia())
|
||||||
|
|
||||||
|
function createTestGraph() {
|
||||||
|
const graph = new LGraph()
|
||||||
|
const node = new LGraphNode('test')
|
||||||
|
node.addInput('input', 'INT')
|
||||||
|
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||||
|
graph.add(node)
|
||||||
|
|
||||||
|
const { vueNodeData } = useGraphNodeManager(graph)
|
||||||
|
const onReactivityUpdate = vi.fn()
|
||||||
|
watch(vueNodeData, onReactivityUpdate)
|
||||||
|
|
||||||
|
return [node, graph, onReactivityUpdate] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Node Reactivity', () => {
|
||||||
|
it('should trigger on callback', async () => {
|
||||||
|
const [node, , onReactivityUpdate] = createTestGraph()
|
||||||
|
|
||||||
|
node.widgets![0].callback!(2)
|
||||||
|
await nextTick()
|
||||||
|
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remain reactive after a connection is made', async () => {
|
||||||
|
const [node, graph, onReactivityUpdate] = createTestGraph()
|
||||||
|
|
||||||
|
graph.trigger('node:slot-links:changed', {
|
||||||
|
nodeId: '1',
|
||||||
|
slotType: NodeSlotType.INPUT
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
onReactivityUpdate.mockClear()
|
||||||
|
|
||||||
|
node.widgets![0].callback!(2)
|
||||||
|
await nextTick()
|
||||||
|
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1664,31 +1664,41 @@ describe('useNodePricing', () => {
|
|||||||
{
|
{
|
||||||
model: 'gemini-2.5-pro-preview-05-06',
|
model: 'gemini-2.5-pro-preview-05-06',
|
||||||
expected: creditsListLabel([0.00125, 0.01], {
|
expected: creditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
expected: creditsListLabel([0.00125, 0.01], {
|
expected: creditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3-pro-preview',
|
||||||
expected: creditsListLabel([0.002, 0.012], {
|
expected: creditsListLabel([0.002, 0.012], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gemini-2.5-flash-preview-04-17',
|
model: 'gemini-2.5-flash-preview-04-17',
|
||||||
expected: creditsListLabel([0.0003, 0.0025], {
|
expected: creditsListLabel([0.0003, 0.0025], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-2.5-flash',
|
||||||
expected: creditsListLabel([0.0003, 0.0025], {
|
expected: creditsListLabel([0.0003, 0.0025], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ model: 'unknown-gemini-model', expected: 'Token-based' }
|
{ model: 'unknown-gemini-model', expected: 'Token-based' }
|
||||||
@@ -1702,16 +1712,6 @@ describe('useNodePricing', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return per-second pricing for Gemini Veo models', () => {
|
|
||||||
const { getNodeDisplayPrice } = useNodePricing()
|
|
||||||
const node = createMockNode('GeminiNode', [
|
|
||||||
{ name: 'model', value: 'veo-2.0-generate-001' }
|
|
||||||
])
|
|
||||||
|
|
||||||
const price = getNodeDisplayPrice(node)
|
|
||||||
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return fallback for GeminiNode without model widget', () => {
|
it('should return fallback for GeminiNode without model widget', () => {
|
||||||
const { getNodeDisplayPrice } = useNodePricing()
|
const { getNodeDisplayPrice } = useNodePricing()
|
||||||
const node = createMockNode('GeminiNode', [])
|
const node = createMockNode('GeminiNode', [])
|
||||||
@@ -1737,73 +1737,97 @@ describe('useNodePricing', () => {
|
|||||||
{
|
{
|
||||||
model: 'o4-mini',
|
model: 'o4-mini',
|
||||||
expected: creditsListLabel([0.0011, 0.0044], {
|
expected: creditsListLabel([0.0011, 0.0044], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o1-pro',
|
model: 'o1-pro',
|
||||||
expected: creditsListLabel([0.15, 0.6], {
|
expected: creditsListLabel([0.15, 0.6], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o1',
|
model: 'o1',
|
||||||
expected: creditsListLabel([0.015, 0.06], {
|
expected: creditsListLabel([0.015, 0.06], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o3-mini',
|
model: 'o3-mini',
|
||||||
expected: creditsListLabel([0.0011, 0.0044], {
|
expected: creditsListLabel([0.0011, 0.0044], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o3',
|
model: 'o3',
|
||||||
expected: creditsListLabel([0.01, 0.04], {
|
expected: creditsListLabel([0.01, 0.04], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4o',
|
model: 'gpt-4o',
|
||||||
expected: creditsListLabel([0.0025, 0.01], {
|
expected: creditsListLabel([0.0025, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4.1-nano',
|
model: 'gpt-4.1-nano',
|
||||||
expected: creditsListLabel([0.0001, 0.0004], {
|
expected: creditsListLabel([0.0001, 0.0004], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4.1-mini',
|
model: 'gpt-4.1-mini',
|
||||||
expected: creditsListLabel([0.0004, 0.0016], {
|
expected: creditsListLabel([0.0004, 0.0016], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4.1',
|
model: 'gpt-4.1',
|
||||||
expected: creditsListLabel([0.002, 0.008], {
|
expected: creditsListLabel([0.002, 0.008], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-5-nano',
|
model: 'gpt-5-nano',
|
||||||
expected: creditsListLabel([0.00005, 0.0004], {
|
expected: creditsListLabel([0.00005, 0.0004], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-5-mini',
|
model: 'gpt-5-mini',
|
||||||
expected: creditsListLabel([0.00025, 0.002], {
|
expected: creditsListLabel([0.00025, 0.002], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
expected: creditsListLabel([0.00125, 0.01], {
|
expected: creditsListLabel([0.00125, 0.01], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1824,37 +1848,49 @@ describe('useNodePricing', () => {
|
|||||||
{
|
{
|
||||||
model: 'gpt-4.1-nano-test',
|
model: 'gpt-4.1-nano-test',
|
||||||
expected: creditsListLabel([0.0001, 0.0004], {
|
expected: creditsListLabel([0.0001, 0.0004], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4.1-mini-test',
|
model: 'gpt-4.1-mini-test',
|
||||||
expected: creditsListLabel([0.0004, 0.0016], {
|
expected: creditsListLabel([0.0004, 0.0016], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'gpt-4.1-test',
|
model: 'gpt-4.1-test',
|
||||||
expected: creditsListLabel([0.002, 0.008], {
|
expected: creditsListLabel([0.002, 0.008], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o1-pro-test',
|
model: 'o1-pro-test',
|
||||||
expected: creditsListLabel([0.15, 0.6], {
|
expected: creditsListLabel([0.15, 0.6], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o1-test',
|
model: 'o1-test',
|
||||||
expected: creditsListLabel([0.015, 0.06], {
|
expected: creditsListLabel([0.015, 0.06], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: 'o3-mini-test',
|
model: 'o3-mini-test',
|
||||||
expected: creditsListLabel([0.0011, 0.0044], {
|
expected: creditsListLabel([0.0011, 0.0044], {
|
||||||
suffix: ' per 1K tokens'
|
suffix: ' per 1K tokens',
|
||||||
|
approximate: true,
|
||||||
|
separator: '-'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ model: 'unknown-model', expected: 'Token-based' }
|
{ model: 'unknown-model', expected: 'Token-based' }
|
||||||
|
|||||||