mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-02 02:29:09 +00:00
Compare commits
12 Commits
edit-node-
...
core/1.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764066f4da | ||
|
|
ec8e9cdb79 | ||
|
|
c071aff1ab | ||
|
|
84b039ac4d | ||
|
|
8eec10dced | ||
|
|
95a5736da0 | ||
|
|
a244f295a6 | ||
|
|
3e54146afd | ||
|
|
3b051a11a4 | ||
|
|
792c5f2246 | ||
|
|
96768bba97 | ||
|
|
fc39ce9624 |
@@ -21,8 +21,5 @@ DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
|
||||
# If you aren't using a separate install for testing, point this to your regular install.
|
||||
TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
|
||||
# The directory containing the ComfyUI_examples repo used to extract test workflows.
|
||||
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
|
||||
|
||||
# Whether to enable minification of the frontend code.
|
||||
ENABLE_MINIFY=true
|
||||
|
||||
64
.github/workflows/release.yaml
vendored
64
.github/workflows/release.yaml
vendored
@@ -8,11 +8,13 @@ on:
|
||||
- 'package.json'
|
||||
|
||||
jobs:
|
||||
draft_release:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'Release')
|
||||
outputs:
|
||||
version: ${{ steps.current_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -21,7 +23,7 @@ jobs:
|
||||
node-version: 'lts/*'
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
@@ -29,6 +31,24 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
npm run zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -37,17 +57,47 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: ${{ github.event.pull_request.base.ref == 'main' }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
publish_types:
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
publish_types:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'Release')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
170
.github/workflows/test-ui.yaml
vendored
170
.github/workflows/test-ui.yaml
vendored
@@ -2,76 +2,122 @@ name: Tests CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, core/*, desktop/* ]
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
pull_request:
|
||||
branches: [ main, master, dev*, core/*, desktop/* ]
|
||||
branches: [main, master, dev*, core/*, desktop/*]
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
ref: master
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||
ref: '080e6d4af809a46852d1c4b7ed85f06e8a3a72be'
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Build & Install ComfyUI_frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
rm -rf ../ComfyUI/web/*
|
||||
mv dist/* ../ComfyUI/web/
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Generate cache key
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache setup
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
|
||||
|
||||
jest-tests:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 080e6d4af809a46852d1c4b7ed85f06e8a3a72be
|
||||
- name: Run Jest tests
|
||||
run: |
|
||||
npm run test:generate
|
||||
npm run test:jest -- --verbose
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
playwright-tests-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 080e6d4af809a46852d1c4b7ed85f06e8a3a72be
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Run Playwright tests (Chromium)
|
||||
run: npx playwright test --project=chromium
|
||||
working-directory: ComfyUI_frontend
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-chromium
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Run Jest tests
|
||||
run: |
|
||||
npm run test:jest -- --verbose
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
playwright-tests-chromium-2x:
|
||||
playwright-tests:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 080e6d4af809a46852d1c4b7ed85f06e8a3a72be
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Run Playwright tests (Chromium 2x)
|
||||
run: npx playwright test --project=chromium-2x
|
||||
working-directory: ComfyUI_frontend
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-chromium-2x
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
playwright-tests-mobile-chrome:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.2
|
||||
with:
|
||||
devtools_ref: 080e6d4af809a46852d1c4b7ed85f06e8a3a72be
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Run Playwright tests (Mobile Chrome)
|
||||
run: npx playwright test --project=mobile-chrome
|
||||
working-directory: ComfyUI_frontend
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-mobile-chrome
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install requirements
|
||||
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
|
||||
working-directory: ComfyUI
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
run: npx playwright test --project=${{ matrix.browser }}
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -546,9 +546,7 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
|
||||
- `npm i` to install all dependencies
|
||||
- `npm run test:generate` to fetch `tests-ui/data/object_info.json`
|
||||
- `npm run test:jest` to execute all unit tests.
|
||||
|
||||
### Component Test
|
||||
|
||||
BIN
browser_tests/assets/workflow.webm
Normal file
BIN
browser_tests/assets/workflow.webm
Normal file
Binary file not shown.
@@ -468,6 +468,7 @@ export class ComfyPage {
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ test.describe('Load Workflow in Media', () => {
|
||||
'workflow.webp',
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp'
|
||||
'large_workflow.webp',
|
||||
'workflow.webm'
|
||||
].forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
|
||||
await comfyPage.dragAndDropFile(fileName)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
4
comfyui_frontend_package/.gitignore
vendored
Normal file
4
comfyui_frontend_package/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
comfyui_frontend_package/static/*
|
||||
comfyui_frontend_package.egg-info/*
|
||||
|
||||
__pycache__/
|
||||
1
comfyui_frontend_package/MANIFEST.in
Normal file
1
comfyui_frontend_package/MANIFEST.in
Normal file
@@ -0,0 +1 @@
|
||||
recursive-include comfyui_frontend_package/static *
|
||||
13
comfyui_frontend_package/README.md
Normal file
13
comfyui_frontend_package/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# comfyui_frontend pypi package
|
||||
|
||||
This is the pypi package structure for the comfyui frontend.
|
||||
|
||||
During build process, the compiled assets are copied into the `${PROJECT_ROOT}/comfyui_frontend_package/comfyui_frontend_package/static` directory.
|
||||
|
||||
The package can be installed with the following command:
|
||||
|
||||
```bash
|
||||
pip install comfyui-frontend-package
|
||||
```
|
||||
|
||||
Ref: <https://pypi.org/project/comfyui-frontend-package/>
|
||||
@@ -0,0 +1,6 @@
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("comfyui_frontend_package")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
11
comfyui_frontend_package/setup.py
Normal file
11
comfyui_frontend_package/setup.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="comfyui_frontend_package",
|
||||
version=os.getenv("COMFYUI_FRONTEND_VERSION") or "0.1.0",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[],
|
||||
python_requires=">=3.9",
|
||||
)
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.11.4",
|
||||
"version": "1.11.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.11.4",
|
||||
"version": "1.11.8",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.20",
|
||||
"@comfyorg/litegraph": "^0.8.99",
|
||||
"@comfyorg/litegraph": "^0.8.99-patch.1",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -1944,9 +1944,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.8.99",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.99.tgz",
|
||||
"integrity": "sha512-XpIEX9DB0hhcMer/nwP/Fz7QuV8dBaOKXitJosTilozcDuIOBCiprbrqBXKYkPLUQUZ99MBqO68kokv74w3U9w==",
|
||||
"version": "0.8.99-patch.1",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.99-patch.1.tgz",
|
||||
"integrity": "sha512-DIhgJbN94SfOeB+z8WLhpDn/S1gSY2lQ64AT8z6W9AIiW1KB9nfX+Zab0fwRDd2YQfsxNBoaXSZdCH9D8BjrUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.11.4",
|
||||
"version": "1.11.8",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -20,7 +20,6 @@
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:jest": "jest --config jest.config.ts",
|
||||
"test:generate": "npx tsx tests-ui/setup",
|
||||
"test:browser": "npx playwright test",
|
||||
"test:component": "vitest run src/components/",
|
||||
"prepare": "husky || true",
|
||||
@@ -84,7 +83,7 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.20",
|
||||
"@comfyorg/litegraph": "^0.8.99",
|
||||
"@comfyorg/litegraph": "^0.8.99-patch.1",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -3,117 +3,76 @@
|
||||
class="selection-toolbox absolute left-1/2 rounded-lg"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-0'
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row">
|
||||
<ColorPickerButton v-show="nodeSelected || groupSelected" />
|
||||
<Button
|
||||
v-show="nodeSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="
|
||||
() =>
|
||||
commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
data-testid="bypass-button"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-show="nodeSelected || groupSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
<Button
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
<Button
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="
|
||||
typeof command.icon === 'function' ? command.icon() : command.icon
|
||||
"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
/>
|
||||
<Divider
|
||||
layout="vertical"
|
||||
class="mx-1 my-2"
|
||||
v-if="hasAdvancedOptions"
|
||||
/>
|
||||
<Button
|
||||
v-if="hasAdvancedOptions"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="showAdvancedOptions ? 'pi pi-chevron-up' : 'pi pi-ellipsis-h'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showAdvancedOptions" class="flex flex-row">
|
||||
<NodeModelsButton
|
||||
v-if="modelNodeSelected && canvasStore.selectedItems.length === 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ColorPickerButton v-show="nodeSelected || groupSelected" />
|
||||
<Button
|
||||
v-show="nodeSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
data-testid="bypass-button"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-show="nodeSelected || groupSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
<Button
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
<Button
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import NodeModelsButton from '@/components/graph/selectionToolbox/nodeModelsMetadata/NodeModelsButton.vue'
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isLGraphGroup, isLGraphNode, isModelNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
const selectedNodes = computed(() =>
|
||||
canvasStore.selectedItems.filter(isLGraphNode)
|
||||
const nodeSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphNode)
|
||||
)
|
||||
const selectedModelNodes = computed(() =>
|
||||
selectedNodes.value.filter(isModelNode)
|
||||
const groupSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphGroup)
|
||||
)
|
||||
const selectedGroups = computed(() =>
|
||||
canvasStore.selectedItems.filter(isLGraphGroup)
|
||||
)
|
||||
const nodeSelected = computed(() => selectedNodes.value.length > 0)
|
||||
const groupSelected = computed(() => selectedGroups.value.length > 0)
|
||||
const modelNodeSelected = computed(() => selectedModelNodes.value.length > 0)
|
||||
|
||||
const showModelMetadataTool = computed(
|
||||
() => modelNodeSelected.value && canvasStore.selectedItems.length === 1
|
||||
)
|
||||
const hasAdvancedOptions = computed(() => showModelMetadataTool.value)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
:resolver="zodResolver(zModelFile)"
|
||||
:initial-values="node.properties?.models?.[index]"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="p-2 surface-ground rounded-lg">
|
||||
<Message
|
||||
v-if="$form.name?.error || $form.directory?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ $form.name?.error?.message || $form.directory?.error?.message }}
|
||||
</Message>
|
||||
<div class="flex flex-col gap-2">
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-file" />
|
||||
</InputGroupAddon>
|
||||
<FormField v-slot="$field" name="name">
|
||||
<IftaLabel>
|
||||
<InputText
|
||||
v-bind="$field"
|
||||
:inputId="`model-name-${index}`"
|
||||
class="h-full"
|
||||
/>
|
||||
<label :for="`model-name-${index}`">
|
||||
{{ $t('nodeMetadata.models.fields.filename') }}
|
||||
</label>
|
||||
</IftaLabel>
|
||||
</FormField>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-folder" />
|
||||
</InputGroupAddon>
|
||||
<FormField v-slot="$field" name="directory">
|
||||
<IftaLabel>
|
||||
<InputText
|
||||
v-bind="$field"
|
||||
:inputId="`model-directory-${index}`"
|
||||
class="h-full"
|
||||
/>
|
||||
<label :for="`model-directory-${index}`">
|
||||
{{ $t('nodeMetadata.models.fields.directory') }}
|
||||
</label>
|
||||
</IftaLabel>
|
||||
</FormField>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-link" />
|
||||
</InputGroupAddon>
|
||||
<FormField v-slot="$field" name="url">
|
||||
<IftaLabel>
|
||||
<InputText v-bind="$field" :inputId="`model-url-${index}`" />
|
||||
<label :for="`model-url-${index}`">
|
||||
{{ $t('nodeMetadata.models.fields.url') }}
|
||||
</label>
|
||||
</IftaLabel>
|
||||
</FormField>
|
||||
</InputGroup>
|
||||
<Message
|
||||
v-if="$form.url?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ $form.url?.error?.message }}
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 flex justify-center gap-2">
|
||||
<Button
|
||||
v-tooltip="$t('nodeMetadata.models.remove')"
|
||||
icon="pi pi-minus"
|
||||
severity="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isLast"
|
||||
v-tooltip="$t('nodeMetadata.models.add')"
|
||||
icon="pi pi-plus"
|
||||
text
|
||||
size="small"
|
||||
@click="emit('add')"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:disabled="!$form.valid"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { InputGroup } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import IftaLabel from 'primevue/iftalabel'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { type ModelFile, zModelFile } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { nodeId, index } = defineProps<{
|
||||
index: number
|
||||
isLast: boolean
|
||||
nodeId: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove'): void
|
||||
(e: 'add'): void
|
||||
}>()
|
||||
|
||||
const node = computed(() => app.graph.getNodeById(nodeId))
|
||||
|
||||
const handleSubmit = (event: { values: ModelFile; valid: boolean }) => {
|
||||
if (!event.valid) return
|
||||
|
||||
node.value.properties ||= {}
|
||||
node.value.properties.models ||= []
|
||||
node.value.properties.models[index] = event.values
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('nodeMetadata.models.success'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
@click="overlayRef?.toggle($event)"
|
||||
v-tooltip.top="$t('nodeMetadata.models.title')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="pi pi-box" />
|
||||
</template>
|
||||
</Button>
|
||||
<OverlayPanel ref="overlayRef" class="surface-card">
|
||||
<NodeModelsPopover />
|
||||
</OverlayPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import OverlayPanel from 'primevue/overlaypanel'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import NodeModelsPopover from '@/components/graph/selectionToolbox/nodeModelsMetadata/NodeModelsPopover.vue'
|
||||
|
||||
const overlayRef = ref()
|
||||
</script>
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<template v-if="node">
|
||||
<div ref="contentRef" class="flex flex-col overflow-y-auto px-4 py-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span>{{ $t('nodeMetadata.models.title') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<template
|
||||
v-for="(model, index) of nodeModels"
|
||||
:key="`${model.name}${model.url || model.hash}`"
|
||||
>
|
||||
<ModelForm
|
||||
:model="model"
|
||||
:index="index"
|
||||
:node-id="node.id"
|
||||
:is-last="isLastModel(index)"
|
||||
@remove="removeModel(index)"
|
||||
@add="addEmptyModel"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="nodeModels.length === 0" class="flex items-center">
|
||||
<div class="flex-1 flex justify-center">
|
||||
<Button
|
||||
v-tooltip="$t('nodeMetadata.models.add')"
|
||||
icon="pi pi-plus"
|
||||
text
|
||||
size="small"
|
||||
@click="addEmptyModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import ModelForm from '@/components/graph/selectionToolbox/nodeModelsMetadata/ModelForm.vue'
|
||||
import type { ModelFile } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isModelNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const nodeModels = ref<ModelFile[]>([])
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
const isLastModel = (index: number) => index === nodeModels.value.length - 1
|
||||
const formatMaxHeight = (top: number) => `calc(100vh - ${top}px)`
|
||||
|
||||
const addEmptyModel = () => {
|
||||
nodeModels.value.push({ name: '', url: '', directory: '' })
|
||||
}
|
||||
const removeModel = (index: number) => {
|
||||
nodeModels.value.splice(index, 1)
|
||||
const models = node.value?.properties?.models as ModelFile[]
|
||||
if (models) models.splice(index, 1)
|
||||
}
|
||||
|
||||
useResizeObserver(contentRef, () => {
|
||||
if (contentRef.value) {
|
||||
contentRef.value.style.maxHeight = formatMaxHeight(
|
||||
contentRef.value.getBoundingClientRect().top
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const nodes = canvasStore.selectedItems.filter(isModelNode)
|
||||
node.value = nodes[0]
|
||||
|
||||
if (node.value?.properties?.models) {
|
||||
nodeModels.value = [...(node.value.properties.models as ModelFile[])]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import _ from 'lodash'
|
||||
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { getNumberDefaults } from '@/utils/mathUtil'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
this.value = this.options.round
|
||||
? _.clamp(
|
||||
Math.round((v + Number.EPSILON) / this.options.round) *
|
||||
this.options.round,
|
||||
this.options.min ?? -Infinity,
|
||||
this.options.max ?? Infinity
|
||||
)
|
||||
: v
|
||||
}
|
||||
|
||||
export const _for_testing = {
|
||||
onFloatValueChange
|
||||
}
|
||||
|
||||
export const useFloatWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
@@ -40,16 +56,7 @@ export const useFloatWidget = () => {
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (this: INumericWidget, v: number) {
|
||||
if (config.round) {
|
||||
this.value =
|
||||
Math.round((v + Number.EPSILON) / config.round) * config.round
|
||||
if (this.value > config.max) this.value = config.max
|
||||
if (this.value < config.min) this.value = config.min
|
||||
} else {
|
||||
this.value = v
|
||||
}
|
||||
},
|
||||
onFloatValueChange,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,29 @@ import {
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { getNumberDefaults } from '@/utils/mathUtil'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
// For integers, always round to the nearest step
|
||||
// step === 0 is invalid, assign 1 if options.step is 0
|
||||
const step = this.options.step2 || 1
|
||||
|
||||
if (step === 1) {
|
||||
// Simple case: round to nearest integer
|
||||
this.value = Math.round(v)
|
||||
} else {
|
||||
// Round to nearest multiple of step
|
||||
// First, determine if min value creates an offset
|
||||
const min = this.options.min ?? 0
|
||||
const offset = min % step
|
||||
|
||||
// Round to nearest step, accounting for offset
|
||||
this.value = Math.round((v - offset) / step) * step + offset
|
||||
}
|
||||
}
|
||||
|
||||
export const _for_testing = {
|
||||
onValueChange
|
||||
}
|
||||
|
||||
export const useIntWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
@@ -35,20 +58,7 @@ export const useIntWidget = () => {
|
||||
config.precision = 0
|
||||
|
||||
const result = {
|
||||
widget: node.addWidget(
|
||||
widgetType,
|
||||
inputName,
|
||||
val,
|
||||
function (this: INumericWidget, v: number) {
|
||||
const s = this.options.step2 || 1
|
||||
let sh = (this.options.min ?? 0) % s
|
||||
if (isNaN(sh)) {
|
||||
sh = 0
|
||||
}
|
||||
this.value = Math.round((v - sh) / s) * s + sh
|
||||
},
|
||||
config
|
||||
)
|
||||
widget: node.addWidget(widgetType, inputName, val, onValueChange, config)
|
||||
}
|
||||
|
||||
if (inputData[1]?.control_after_generate) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const PYTHON_MIRROR: UVMirror = {
|
||||
mirror:
|
||||
'https://github.com/astral-sh/python-build-standalone/releases/download',
|
||||
fallbackMirror:
|
||||
'https://bgithub.xyz/astral-sh/python-build-standalone/releases/download',
|
||||
'https://python-standalone.org/mirror/astral-sh/python-build-standalone',
|
||||
validationPathSuffix:
|
||||
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
|
||||
}
|
||||
|
||||
@@ -80,19 +80,6 @@
|
||||
"choose_file_to_upload": "choose file to upload",
|
||||
"capture": "capture"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"title": "Edit Node Models Metadata",
|
||||
"add": "Add Model",
|
||||
"remove": "Remove Model",
|
||||
"success": "Node models metadata updated",
|
||||
"fields": {
|
||||
"filename": "Filename",
|
||||
"directory": "Directory",
|
||||
"url": "URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issueReport": {
|
||||
"submitErrorReport": "Submit Error Report (Optional)",
|
||||
"provideEmail": "Give us your email (optional)",
|
||||
|
||||
@@ -515,19 +515,6 @@
|
||||
"video": "vidéo",
|
||||
"video_models": "modèles_vidéo"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"add": "Ajouter un modèle",
|
||||
"fields": {
|
||||
"directory": "Répertoire",
|
||||
"filename": "Nom de fichier",
|
||||
"url": "URL"
|
||||
},
|
||||
"remove": "Supprimer le modèle",
|
||||
"success": "Métadonnées des modèles de nœuds mises à jour",
|
||||
"title": "Modifier les métadonnées des modèles de nœuds"
|
||||
}
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Entrez le nom",
|
||||
"saveAsTemplate": "Enregistrer comme modèle"
|
||||
|
||||
@@ -515,19 +515,6 @@
|
||||
"video": "ビデオ",
|
||||
"video_models": "ビデオモデル"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"add": "モデルを追加",
|
||||
"fields": {
|
||||
"directory": "ディレクトリ",
|
||||
"filename": "ファイル名",
|
||||
"url": "URL"
|
||||
},
|
||||
"remove": "モデルを削除",
|
||||
"success": "ノードモデルのメタデータが更新されました",
|
||||
"title": "ノードモデルメタデータの編集"
|
||||
}
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "名前を入力",
|
||||
"saveAsTemplate": "テンプレートとして保存"
|
||||
|
||||
@@ -515,19 +515,6 @@
|
||||
"video": "비디오",
|
||||
"video_models": "비디오 모델"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"add": "모델 추가",
|
||||
"fields": {
|
||||
"directory": "디렉토리",
|
||||
"filename": "파일명",
|
||||
"url": "URL"
|
||||
},
|
||||
"remove": "모델 제거",
|
||||
"success": "노드 모델 메타데이터가 업데이트되었습니다",
|
||||
"title": "노드 모델 메타데이터 편집"
|
||||
}
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "이름 입력",
|
||||
"saveAsTemplate": "템플릿으로 저장"
|
||||
|
||||
@@ -515,19 +515,6 @@
|
||||
"video": "видео",
|
||||
"video_models": "видеомодели"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"add": "Добавить модель",
|
||||
"fields": {
|
||||
"directory": "Директория",
|
||||
"filename": "Имя файла",
|
||||
"url": "URL"
|
||||
},
|
||||
"remove": "Удалить модель",
|
||||
"success": "Метаданные моделей узла обновлены",
|
||||
"title": "Редактирование метаданных моделей узлов"
|
||||
}
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Введите название",
|
||||
"saveAsTemplate": "Сохранить как шаблон"
|
||||
|
||||
@@ -515,19 +515,6 @@
|
||||
"video": "视频",
|
||||
"video_models": "视频模型"
|
||||
},
|
||||
"nodeMetadata": {
|
||||
"models": {
|
||||
"add": "添加模型",
|
||||
"fields": {
|
||||
"directory": "目录",
|
||||
"filename": "文件名",
|
||||
"url": "URL"
|
||||
},
|
||||
"remove": "移除模型",
|
||||
"success": "节点模型元数据已更新",
|
||||
"title": "编辑节点模型元数据"
|
||||
}
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "输入名称",
|
||||
"saveAsTemplate": "另存为模板"
|
||||
|
||||
@@ -31,12 +31,12 @@ const zVector2 = z.union([
|
||||
])
|
||||
|
||||
// Definition of an AI model file used in the workflow.
|
||||
export const zModelFile = z.object({
|
||||
name: z.string().min(1, 'Name cannot be empty'),
|
||||
const zModelFile = z.object({
|
||||
name: z.string(),
|
||||
url: z.string().url(),
|
||||
hash: z.string().optional(),
|
||||
hash_type: z.string().optional(),
|
||||
directory: z.string().min(1, 'Directory cannot be empty')
|
||||
directory: z.string()
|
||||
})
|
||||
|
||||
const zGraphState = z
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
validateComfyWorkflow
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -1408,6 +1409,15 @@ export class ComfyApp {
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
} else if (webmInfo.prompt) {
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'application/json' ||
|
||||
file.name?.endsWith('.json')
|
||||
|
||||
358
src/scripts/metadata/ebml.ts
Normal file
358
src/scripts/metadata/ebml.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
type ComfyApiWorkflow,
|
||||
type ComfyWorkflowJSON
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import {
|
||||
ComfyMetadata,
|
||||
ComfyMetadataTags,
|
||||
EbmlElementRange,
|
||||
EbmlTagPosition,
|
||||
TextRange,
|
||||
VInt
|
||||
} from '@/types/metadataTypes'
|
||||
|
||||
const WEBM_SIGNATURE = [0x1a, 0x45, 0xdf, 0xa3]
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024
|
||||
const EBML_ID = {
|
||||
SIMPLE_TAG: new Uint8Array([0x67, 0xc8]),
|
||||
TAG_NAME: new Uint8Array([0x45, 0xa3]),
|
||||
TAG_VALUE: new Uint8Array([0x44, 0x87])
|
||||
}
|
||||
const ASCII = {
|
||||
OPEN_BRACE: 0x7b,
|
||||
NULL: 0,
|
||||
PRINTABLE_MIN: 32,
|
||||
PRINTABLE_MAX: 126
|
||||
}
|
||||
const MASK = {
|
||||
MSB: 0x80,
|
||||
ALL_BITS_SET: -1
|
||||
}
|
||||
|
||||
const hasWebmSignature = (data: Uint8Array): boolean =>
|
||||
WEBM_SIGNATURE.every((byte, index) => data[index] === byte)
|
||||
|
||||
const readVint = (data: Uint8Array, pos: number): VInt | null => {
|
||||
if (pos >= data.length) return null
|
||||
|
||||
const byte = data[pos]
|
||||
|
||||
// Fast path for common case (1-byte vint)
|
||||
if ((byte & MASK.MSB) === MASK.MSB) {
|
||||
return { value: byte & ~MASK.MSB, length: 1 }
|
||||
}
|
||||
|
||||
const length = findFirstSetBitPosition(byte)
|
||||
if (length === 0 || pos + length > data.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
value: calculateVintValue(data, pos, length),
|
||||
length
|
||||
}
|
||||
}
|
||||
|
||||
const calculateVintValue = (
|
||||
data: Uint8Array,
|
||||
pos: number,
|
||||
length: number
|
||||
): number => {
|
||||
let value = data[pos] & (0xff >> length)
|
||||
|
||||
for (let i = 1; i < length; i++) {
|
||||
value = (value << 8) | data[pos + i]
|
||||
}
|
||||
|
||||
const allBitsSet = Math.pow(2, 7 * length) - 1
|
||||
return value === allBitsSet ? MASK.ALL_BITS_SET : value
|
||||
}
|
||||
|
||||
const findFirstSetBitPosition = (byte: number): number => {
|
||||
for (let mask = MASK.MSB, position = 1; mask !== 0; mask >>= 1, position++) {
|
||||
if ((byte & mask) !== 0) return position
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const matchesId = (data: Uint8Array, pos: number, id: Uint8Array): boolean => {
|
||||
if (pos + id.length > data.length) return false
|
||||
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
if (data[pos + i] !== id[i]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const findNextTag = (
|
||||
data: Uint8Array,
|
||||
pos: number
|
||||
): { tagEnd: number; contents: EbmlTagPosition | null } | null => {
|
||||
if (!matchesId(data, pos, EBML_ID.SIMPLE_TAG)) return null
|
||||
|
||||
const tagSize = readVint(data, pos + 2)
|
||||
if (!tagSize || tagSize.value <= 0) return null
|
||||
|
||||
const tagEnd = pos + 2 + tagSize.length + tagSize.value
|
||||
if (tagEnd > data.length) return null
|
||||
|
||||
const contents = extractTagContents(data, pos + 2 + tagSize.length, tagEnd)
|
||||
return { tagEnd, contents }
|
||||
}
|
||||
|
||||
const extractTagContents = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): EbmlTagPosition | null => {
|
||||
const nameInfo = findElementsInTag(data, start, end, EBML_ID.TAG_NAME)
|
||||
const valueInfo = findElementsInTag(data, start, end, EBML_ID.TAG_VALUE)
|
||||
|
||||
if (!nameInfo || !valueInfo) return null
|
||||
|
||||
return {
|
||||
name: nameInfo,
|
||||
value: valueInfo
|
||||
}
|
||||
}
|
||||
|
||||
const findElementsInTag = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
end: number,
|
||||
id: Uint8Array
|
||||
): EbmlElementRange | null => {
|
||||
for (let pos = start; pos < end - 1; ) {
|
||||
if (matchesId(data, pos, id)) {
|
||||
const size = readVint(data, pos + 2)
|
||||
if (size && size.value > 0) {
|
||||
const elementPos = pos + 2 + size.length
|
||||
return { pos: elementPos, len: size.value }
|
||||
}
|
||||
}
|
||||
pos++
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const processTagContents = (
|
||||
data: Uint8Array,
|
||||
contents: EbmlTagPosition,
|
||||
meta: ComfyMetadata
|
||||
) => {
|
||||
try {
|
||||
const name = extractEbmlTagName(data, contents.name.pos, contents.name.len)
|
||||
if (!name) return
|
||||
|
||||
const value = extractEbmlTagValue(
|
||||
data,
|
||||
name,
|
||||
contents.value.pos,
|
||||
contents.value.len
|
||||
)
|
||||
if (value !== null) {
|
||||
meta[name.toLowerCase()] = value
|
||||
}
|
||||
} catch {
|
||||
// Silently continue on error
|
||||
}
|
||||
}
|
||||
|
||||
const extractEbmlTagValue = (
|
||||
data: Uint8Array,
|
||||
name: string,
|
||||
pos: number,
|
||||
len: number
|
||||
): string | ComfyWorkflowJSON | ComfyApiWorkflow | null => {
|
||||
if (
|
||||
name === ComfyMetadataTags.PROMPT ||
|
||||
name === ComfyMetadataTags.WORKFLOW
|
||||
) {
|
||||
return readJson(data, pos, len)
|
||||
}
|
||||
|
||||
return ebmlToString(data, pos, len)
|
||||
}
|
||||
|
||||
const extractEbmlTagName = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
length: number
|
||||
): string | null => {
|
||||
if (length <= 0) return null
|
||||
|
||||
const textRange = findReadableTextRange(data, start, length)
|
||||
if (!textRange) return null
|
||||
|
||||
return new TextDecoder()
|
||||
.decode(data.subarray(textRange.start, textRange.end))
|
||||
.trim()
|
||||
}
|
||||
|
||||
const findReadableTextRange = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
length: number
|
||||
): TextRange | null => {
|
||||
const isPrintableAscii = (byte: number) =>
|
||||
byte >= ASCII.PRINTABLE_MIN && byte <= ASCII.PRINTABLE_MAX
|
||||
|
||||
let textStart = start
|
||||
while (textStart < start + length && !isPrintableAscii(data[textStart])) {
|
||||
textStart++
|
||||
}
|
||||
|
||||
if (textStart >= start + length) return null
|
||||
|
||||
let textEnd = textStart
|
||||
while (textEnd < start + length && data[textEnd] !== ASCII.NULL) {
|
||||
textEnd++
|
||||
}
|
||||
|
||||
return { start: textStart, end: textEnd }
|
||||
}
|
||||
|
||||
const readJson = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
length: number
|
||||
): ComfyWorkflowJSON | ComfyApiWorkflow | null => {
|
||||
if (length <= 0) return null
|
||||
|
||||
const nullTerminatorPos = findNullTerminator(data, start, length)
|
||||
const jsonStartPos = findJsonStart(data, start, nullTerminatorPos - start)
|
||||
|
||||
if (jsonStartPos === null) return null
|
||||
|
||||
const jsonText = decodeJsonText(data, jsonStartPos, nullTerminatorPos)
|
||||
return parseJsonText(jsonText)
|
||||
}
|
||||
|
||||
const decodeJsonText = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): string => {
|
||||
return new TextDecoder().decode(data.subarray(start, end))
|
||||
}
|
||||
|
||||
const parseJsonText = (
|
||||
jsonText: string
|
||||
): ComfyWorkflowJSON | ComfyApiWorkflow | null => {
|
||||
const jsonEndPos = findJsonEnd(jsonText)
|
||||
if (jsonEndPos === null) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonText.substring(0, jsonEndPos))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const findNullTerminator = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
maxLength: number
|
||||
): number => {
|
||||
const end = Math.min(start + maxLength, data.length)
|
||||
|
||||
for (let pos = start; pos < end; pos++) {
|
||||
if (data[pos] === ASCII.NULL) return pos
|
||||
}
|
||||
|
||||
return end
|
||||
}
|
||||
|
||||
const findJsonStart = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
length: number
|
||||
): number | null => {
|
||||
for (let pos = start; pos < start + length; pos++) {
|
||||
if (data[pos] === ASCII.OPEN_BRACE) return pos
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const findJsonEnd = (text: string): number | null => {
|
||||
let braceCount = 1
|
||||
let pos = 1 // Start after the opening brace
|
||||
|
||||
while (braceCount > 0 && pos < text.length) {
|
||||
if (text[pos] === '{') braceCount++
|
||||
if (text[pos] === '}') braceCount--
|
||||
pos++
|
||||
}
|
||||
|
||||
return braceCount === 0 ? pos : null
|
||||
}
|
||||
|
||||
const ebmlToString = (
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
length: number
|
||||
): string => {
|
||||
if (length <= 0) return ''
|
||||
|
||||
const endPos = findNullTerminator(data, start, length)
|
||||
return new TextDecoder().decode(data.subarray(start, endPos)).trim()
|
||||
}
|
||||
|
||||
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
|
||||
const meta: ComfyMetadata = {}
|
||||
|
||||
for (let pos = 0; pos < data.length - 2; ) {
|
||||
const tagInfo = findNextTag(data, pos)
|
||||
if (!tagInfo) {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
|
||||
const { tagEnd, contents } = tagInfo
|
||||
if (contents) {
|
||||
processTagContents(data, contents, meta)
|
||||
}
|
||||
|
||||
pos = tagEnd
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
const handleFileLoad = (
|
||||
event: ProgressEvent<FileReader>,
|
||||
resolve: (value: ComfyMetadata) => void
|
||||
) => {
|
||||
if (!event.target?.result) {
|
||||
resolve({})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = new Uint8Array(event.target.result as ArrayBuffer)
|
||||
if (data.length < 4 || !hasWebmSignature(data)) {
|
||||
resolve({})
|
||||
return
|
||||
}
|
||||
|
||||
resolve(parseMetadata(data))
|
||||
} catch {
|
||||
resolve({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts ComfyUI Workflow metadata from a WebM file
|
||||
* @param file - The WebM file to extract metadata from
|
||||
*/
|
||||
export function getFromWebmFile(file: File): Promise<ComfyMetadata> {
|
||||
return new Promise<ComfyMetadata>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => handleFileLoad(event, resolve)
|
||||
reader.onerror = () => resolve({})
|
||||
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
|
||||
})
|
||||
}
|
||||
@@ -402,7 +402,12 @@ export const useLitegraphService = () => {
|
||||
if (isNewOutput || isNewPreview) {
|
||||
this.animatedImages = output?.animated?.find(Boolean)
|
||||
|
||||
if (this.animatedImages || isVideoNode(this)) {
|
||||
const isAnimatedWebp =
|
||||
this.animatedImages &&
|
||||
output.images.some((img) => img.filename?.includes('webp'))
|
||||
const isVideo =
|
||||
(this.animatedImages && !isAnimatedWebp) || isVideoNode(this)
|
||||
if (isVideo) {
|
||||
useNodeVideo(this).showPreview()
|
||||
} else {
|
||||
useNodeImage(this).showPreview()
|
||||
|
||||
4
src/types/litegraph-augmentation.d.ts
vendored
4
src/types/litegraph-augmentation.d.ts
vendored
@@ -17,6 +17,10 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
* - If true or undefined, the value will be included in both the API workflow and graph state
|
||||
*/
|
||||
serialize?: boolean
|
||||
/**
|
||||
* Rounding value for numeric float widgets.
|
||||
*/
|
||||
round?: number
|
||||
}
|
||||
|
||||
interface IBaseWidget {
|
||||
|
||||
45
src/types/metadataTypes.ts
Normal file
45
src/types/metadataTypes.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
|
||||
/**
|
||||
* Tag names used in ComfyUI metadata
|
||||
*/
|
||||
export enum ComfyMetadataTags {
|
||||
PROMPT = 'PROMPT',
|
||||
WORKFLOW = 'WORKFLOW'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata extracted from ComfyUI output files
|
||||
*/
|
||||
export interface ComfyMetadata {
|
||||
workflow?: ComfyWorkflowJSON
|
||||
prompt?: ComfyApiWorkflow
|
||||
[key: string]: string | ComfyWorkflowJSON | ComfyApiWorkflow | undefined
|
||||
}
|
||||
|
||||
export type EbmlElementRange = {
|
||||
/** Position in the buffer */
|
||||
pos: number
|
||||
/** Length of the element in bytes */
|
||||
len: number
|
||||
}
|
||||
|
||||
export type EbmlTagPosition = {
|
||||
name: EbmlElementRange
|
||||
value: EbmlElementRange
|
||||
}
|
||||
|
||||
export type VInt = {
|
||||
/** The value of the variable-length integer */
|
||||
value: number
|
||||
/** The length of the variable-length integer in bytes */
|
||||
length: number
|
||||
}
|
||||
|
||||
export type TextRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
@@ -8,33 +8,11 @@ import {
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import _ from 'lodash'
|
||||
|
||||
import { ModelFile } from '@/schemas/comfyWorkflowSchema'
|
||||
|
||||
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
|
||||
type VideoNode = LGraphNode & {
|
||||
videoContainer: HTMLElement | undefined
|
||||
imgs: HTMLVideoElement[] | undefined
|
||||
}
|
||||
type ModelNode = LGraphNode & {
|
||||
properties: {
|
||||
models?: ModelFile[]
|
||||
}
|
||||
}
|
||||
|
||||
const MODEL_OUTPUT_TYPES = new Set([
|
||||
'MODEL',
|
||||
'CLIP',
|
||||
'VAE',
|
||||
'CONTROL_NET',
|
||||
'UPSCALE_MODEL',
|
||||
'CLIP_VISION',
|
||||
'STYLE_MODEL',
|
||||
'GLIGEN',
|
||||
'HOOKS',
|
||||
'UPSCALE_MODEL',
|
||||
'PHOTOMAKER',
|
||||
'SAM_MODEL'
|
||||
])
|
||||
|
||||
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
|
||||
if (!node) return false
|
||||
@@ -53,13 +31,6 @@ export function isAudioNode(node: LGraphNode | undefined): boolean {
|
||||
return !!node && node.previewMediaType === 'audio'
|
||||
}
|
||||
|
||||
export const isModelNode = (node: unknown): node is ModelNode => {
|
||||
if (!isLGraphNode(node)) return false
|
||||
if (node.properties?.models) return true
|
||||
if (!node.outputs?.length) return false
|
||||
return node.outputs.some((output) => MODEL_OUTPUT_TYPES.has(`${output.type}`))
|
||||
}
|
||||
|
||||
export function addToComboValues(widget: IComboWidget, value: string) {
|
||||
if (!widget.options) widget.options = { values: [] }
|
||||
if (!widget.options.values) widget.options.values = []
|
||||
@@ -75,6 +46,7 @@ export const isLGraphNode = (item: unknown): item is LGraphNode => {
|
||||
export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
|
||||
return item instanceof LGraphGroup
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color option of all canvas items if they are all the same.
|
||||
* @param items - The items to get the color option of.
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
/*
|
||||
Script to generate test API json from the ComfyUI_examples repo.
|
||||
Requires the repo to be cloned to the tests-ui directory or specified via the EXAMPLE_REPO_PATH env var.
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
import { getFromFlacBuffer } from '@/scripts/metadata/flac'
|
||||
import { getFromPngBuffer } from '@/scripts/metadata/png'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoPath =
|
||||
process.env.EXAMPLE_REPO_PATH || path.resolve(dirname, 'ComfyUI_examples')
|
||||
const workflowsPath = path.resolve(dirname, 'workflows', 'examples')
|
||||
|
||||
if (!fs.existsSync(repoPath)) {
|
||||
console.error(
|
||||
`ComfyUI_examples repo not found. Please clone this to ${repoPath} or set the EXAMPLE_REPO_PATH env var (see .env_example) and re-run.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(workflowsPath)) {
|
||||
await fs.promises.mkdir(workflowsPath)
|
||||
}
|
||||
|
||||
async function* getFiles(
|
||||
dir: string,
|
||||
...exts: string[]
|
||||
): AsyncGenerator<string, void, void> {
|
||||
const dirents = await fs.promises.readdir(dir, { withFileTypes: true })
|
||||
for (const dirent of dirents) {
|
||||
const res = path.resolve(dir, dirent.name)
|
||||
if (dirent.isDirectory()) {
|
||||
yield* getFiles(res, ...exts)
|
||||
} else if (exts.includes(path.extname(res))) {
|
||||
yield res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateMetadata(metadata: Record<string, string>) {
|
||||
const check = (prop: 'prompt' | 'workflow') => {
|
||||
const v = metadata?.[prop]
|
||||
if (!v) throw `${prop} not found in metadata`
|
||||
try {
|
||||
JSON.parse(v)
|
||||
} catch (error) {
|
||||
throw `${prop} invalid json: ${error.message}`
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
return { prompt: check('prompt'), workflow: check('workflow') }
|
||||
}
|
||||
|
||||
async function hasExampleChanged(
|
||||
existingFilePath: string,
|
||||
exampleJson: string
|
||||
) {
|
||||
return exampleJson !== (await fs.promises.readFile(existingFilePath, 'utf8'))
|
||||
}
|
||||
|
||||
// Example images to ignore as they don't contain workflows
|
||||
const ignore = [
|
||||
'unclip_sunset.png',
|
||||
'unclip_mountains.png',
|
||||
'inpaint_yosemite_inpaint_example.png',
|
||||
'controlnet_shark_depthmap.png',
|
||||
'controlnet_pose_worship.png',
|
||||
'controlnet_pose_present.png',
|
||||
'controlnet_input_scribble_example.png',
|
||||
'controlnet_house_scribble.png'
|
||||
]
|
||||
|
||||
// Find all existing examples so we can check if any are removed/changed
|
||||
const existing = new Set(
|
||||
(await fs.promises.readdir(workflowsPath, { withFileTypes: true }))
|
||||
.filter((d) => d.isFile())
|
||||
.map((d) => path.resolve(workflowsPath, d.name))
|
||||
)
|
||||
|
||||
const results = {
|
||||
new: [],
|
||||
changed: [],
|
||||
unchanged: [],
|
||||
missing: [],
|
||||
failed: []
|
||||
}
|
||||
|
||||
let total = 0
|
||||
for await (const file of getFiles(repoPath, '.png', '.flac')) {
|
||||
const cleanedName = path
|
||||
.relative(repoPath, file)
|
||||
.replaceAll('/', '_')
|
||||
.replaceAll('\\', '_')
|
||||
|
||||
if (ignore.includes(cleanedName)) continue
|
||||
total++
|
||||
|
||||
let metadata: { prompt: string; workflow: string }
|
||||
try {
|
||||
const { buffer } = await fs.promises.readFile(file)
|
||||
switch (path.extname(file)) {
|
||||
case '.png':
|
||||
metadata = await validateMetadata(getFromPngBuffer(buffer))
|
||||
break
|
||||
case '.flac':
|
||||
metadata = await validateMetadata(getFromFlacBuffer(buffer))
|
||||
break
|
||||
}
|
||||
|
||||
const outPath = path.resolve(workflowsPath, cleanedName + '.json')
|
||||
const exampleJson = JSON.stringify(metadata)
|
||||
if (existing.has(outPath)) {
|
||||
existing.delete(outPath)
|
||||
if (await hasExampleChanged(outPath, exampleJson)) {
|
||||
results.changed.push(outPath)
|
||||
} else {
|
||||
// Unchanged, no point in re-saving
|
||||
results.unchanged.push(outPath)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
results.new.push(outPath)
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(outPath, exampleJson, 'utf8')
|
||||
} catch (error) {
|
||||
results.failed.push({ file, error })
|
||||
}
|
||||
}
|
||||
|
||||
// Any workflows left in the existing set are now missing, these will want checking and manually removing
|
||||
results.missing.push(...existing)
|
||||
|
||||
const c = (v: number, gt0: 'red' | 'yellow' | 'green') =>
|
||||
chalk[v > 0 ? gt0 : 'gray'](v)
|
||||
|
||||
console.log(`Processed ${chalk.green(total)} examples`)
|
||||
console.log(` ${chalk.gray(results.unchanged.length)} unchanged`)
|
||||
console.log(` ${c(results.changed.length, 'yellow')} changed`)
|
||||
console.log(` ${c(results.new.length, 'green')} new`)
|
||||
console.log(` ${c(results.missing.length, 'red')} missing`)
|
||||
console.log(` ${c(results.failed.length, 'red')} failed`)
|
||||
|
||||
if (results.missing.length) {
|
||||
console.log()
|
||||
console.log(
|
||||
chalk.red(
|
||||
'The following examples are missing and require manual reviewing & removal:'
|
||||
)
|
||||
)
|
||||
for (const m of results.missing) {
|
||||
console.log(m)
|
||||
}
|
||||
}
|
||||
|
||||
if (results.failed.length) {
|
||||
console.log()
|
||||
console.log(chalk.red('The following examples failed to extract:'))
|
||||
for (const m of results.failed) {
|
||||
console.log(m.file)
|
||||
console.error(m.error)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import http from 'http'
|
||||
import { resolve } from 'path'
|
||||
|
||||
async function setup() {
|
||||
await new Promise<void>((res, rej) => {
|
||||
http
|
||||
.get('http://127.0.0.1:8188/object_info', (resp) => {
|
||||
let data = ''
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
resp.on('end', () => {
|
||||
// Modify the response data to add some checkpoints
|
||||
const objectInfo = JSON.parse(data)
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = [
|
||||
'model1.safetensors',
|
||||
'model2.ckpt'
|
||||
]
|
||||
objectInfo.VAELoader.input.required.vae_name[0] = [
|
||||
'vae1.safetensors',
|
||||
'vae2.ckpt'
|
||||
]
|
||||
|
||||
data = JSON.stringify(objectInfo, undefined, '\t')
|
||||
|
||||
const outDir = resolve('./tests-ui/data')
|
||||
if (!existsSync(outDir)) {
|
||||
mkdirSync(outDir)
|
||||
}
|
||||
|
||||
const outPath = resolve(outDir, 'object_info.json')
|
||||
console.log(
|
||||
`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`
|
||||
)
|
||||
writeFileSync(outPath, data, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
res()
|
||||
})
|
||||
})
|
||||
.on('error', rej)
|
||||
})
|
||||
}
|
||||
|
||||
setup()
|
||||
@@ -1,7 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
validateComfyNodeDef
|
||||
@@ -76,19 +73,4 @@ describe('validateNodeDef', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('Should accept all built-in node definitions', async () => {
|
||||
const nodeDefs = Object.values(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve('./tests-ui/data/object_info.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for (const nodeDef of nodeDefs) {
|
||||
expect(validateComfyNodeDef(nodeDef)).not.toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
78
tests-ui/tests/composables/widgets/useFloatWidget.test.ts
Normal file
78
tests-ui/tests/composables/widgets/useFloatWidget.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { _for_testing } from '@/composables/widgets/useFloatWidget'
|
||||
|
||||
jest.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: jest.fn()
|
||||
}))
|
||||
|
||||
jest.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}))
|
||||
|
||||
const { onFloatValueChange } = _for_testing
|
||||
|
||||
describe('useFloatWidget', () => {
|
||||
describe('onFloatValueChange', () => {
|
||||
let widget: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the widget before each test
|
||||
widget = {
|
||||
options: {},
|
||||
value: 0
|
||||
}
|
||||
})
|
||||
|
||||
it('should not round values when round option is not set', () => {
|
||||
widget.options.round = undefined
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.7)
|
||||
})
|
||||
|
||||
it('should round values based on round option', () => {
|
||||
widget.options.round = 0.5
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.5)
|
||||
|
||||
widget.options.round = 0.1
|
||||
onFloatValueChange.call(widget, 5.74)
|
||||
expect(widget.value).toBe(5.7)
|
||||
|
||||
widget.options.round = 1
|
||||
onFloatValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
|
||||
it('should respect min and max constraints after rounding', () => {
|
||||
widget.options.round = 0.5
|
||||
widget.options.min = 1
|
||||
widget.options.max = 5
|
||||
|
||||
// Should round to 1 and respect min
|
||||
onFloatValueChange.call(widget, 0.7)
|
||||
expect(widget.value).toBe(1)
|
||||
|
||||
// Should round to 5.5 but be clamped to max of 5
|
||||
onFloatValueChange.call(widget, 5.3)
|
||||
expect(widget.value).toBe(5)
|
||||
|
||||
// Should round to 3.5 and be within bounds
|
||||
onFloatValueChange.call(widget, 3.6)
|
||||
expect(widget.value).toBe(3.5)
|
||||
})
|
||||
|
||||
it('should handle Number.EPSILON for precision issues', () => {
|
||||
widget.options.round = 0.1
|
||||
|
||||
// Without Number.EPSILON, 1.35 / 0.1 = 13.499999999999998
|
||||
// which would round to 13 * 0.1 = 1.3 instead of 1.4
|
||||
onFloatValueChange.call(widget, 1.35)
|
||||
expect(widget.value).toBeCloseTo(1.4, 10)
|
||||
|
||||
// Test another edge case
|
||||
onFloatValueChange.call(widget, 2.95)
|
||||
expect(widget.value).toBeCloseTo(3, 10)
|
||||
})
|
||||
})
|
||||
})
|
||||
70
tests-ui/tests/composables/widgets/useIntWidget.test.ts
Normal file
70
tests-ui/tests/composables/widgets/useIntWidget.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { _for_testing } from '@/composables/widgets/useIntWidget'
|
||||
|
||||
jest.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: jest.fn()
|
||||
}))
|
||||
|
||||
jest.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}))
|
||||
|
||||
const { onValueChange } = _for_testing
|
||||
|
||||
describe('useIntWidget', () => {
|
||||
describe('onValueChange', () => {
|
||||
let widget: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the widget before each test
|
||||
widget = {
|
||||
options: {},
|
||||
value: 0
|
||||
}
|
||||
})
|
||||
|
||||
it('should round values based on step size', () => {
|
||||
widget.options.step2 = 0.1
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(5.7)
|
||||
|
||||
widget.options.step2 = 0.5
|
||||
onValueChange.call(widget, 7.3)
|
||||
expect(widget.value).toBe(7.5)
|
||||
|
||||
widget.options.step2 = 1
|
||||
onValueChange.call(widget, 23.4)
|
||||
expect(widget.value).toBe(23)
|
||||
})
|
||||
|
||||
it('should handle undefined step by using default of 1', () => {
|
||||
widget.options.step2 = undefined
|
||||
onValueChange.call(widget, 3.7)
|
||||
expect(widget.value).toBe(4)
|
||||
})
|
||||
|
||||
it('should account for min value offset', () => {
|
||||
widget.options.step2 = 2
|
||||
widget.options.min = 1
|
||||
// 2 valid values between 1.6 are 1 and 3
|
||||
// 1.6 is closer to 1, so it should round to 1
|
||||
onValueChange.call(widget, 1.6)
|
||||
expect(widget.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle undefined min by using default of 0', () => {
|
||||
widget.options.step2 = 2
|
||||
widget.options.min = undefined
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
|
||||
it('should handle NaN shift value', () => {
|
||||
widget.options.step2 = 0
|
||||
widget.options.min = 1
|
||||
onValueChange.call(widget, 5.7)
|
||||
expect(widget.value).toBe(6)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user