Compare commits

...

12 Commits

Author SHA1 Message Date
Chenlei Hu
764066f4da 1.11.8 (#2920) 2025-03-07 18:35:07 -05:00
Chenlei Hu
ec8e9cdb79 1.11.7 (#2911) 2025-03-07 11:29:12 -05:00
Chenlei Hu
c071aff1ab Update litegraph 0.8.99-patch.1 2025-03-07 11:28:08 -05:00
Robin Huang
84b039ac4d Update uv astral python mirror. (#2863) 2025-03-06 13:09:29 -05:00
Christian Byrne
8eec10dced Fix animated webp output preview (#2897) 2025-03-06 13:09:17 -05:00
huchenlei
95a5736da0 1.11.6 2025-02-28 20:16:44 -05:00
Chenlei Hu
a244f295a6 Remove server elements from unit tests (#2777) 2025-02-28 20:01:40 -05:00
Chenlei Hu
3e54146afd [CI] Refactor test-ui gh action (#2776) 2025-02-28 19:46:57 -05:00
Chenlei Hu
3b051a11a4 [CI] Publish comfyui-frontend-package to pypi (#2774) 2025-02-28 18:22:42 -05:00
bymyself
792c5f2246 Load workflows from webm files (#2772)
Co-authored-by: github-actions <github-actions@github.com>
2025-02-28 16:00:02 -05:00
Chenlei Hu
96768bba97 1.11.5 (#2762) 2025-02-27 19:34:20 -05:00
Chenlei Hu
fc39ce9624 Rewrite/Test rounding logic of numeric widgets (#2758) 2025-02-27 17:52:16 -05:00
29 changed files with 824 additions and 346 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

View File

@@ -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'
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
comfyui_frontend_package/static/*
comfyui_frontend_package.egg-info/*
__pycache__/

View File

@@ -0,0 +1 @@
recursive-include comfyui_frontend_package/static *

View 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/>

View File

@@ -0,0 +1,6 @@
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("comfyui_frontend_package")
except PackageNotFoundError:
__version__ = "unknown"

View 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
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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
)
}

View File

@@ -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) {

View File

@@ -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'
}

View File

@@ -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')

View 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))
})
}

View File

@@ -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()

View File

@@ -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 {

View 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
}

View File

@@ -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()
}
}

View File

@@ -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()

View File

@@ -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()
}
})
})

View 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)
})
})
})

View 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)
})
})
})