Compare commits

..

52 Commits

Author SHA1 Message Date
bymyself
4d19bd2f32 add browser test 2025-04-05 10:42:21 -07:00
bymyself
323db0e049 filter unserialized widgets values 2025-04-05 10:35:49 -07:00
Comfy Org PR Bot
3978613f14 1.15.11 (#3322)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-05 17:56:18 +11:00
Comfy Org PR Bot
0a40e07f7e [chore] Update litegraph to 0.11.10 (#3321)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-04-05 17:42:24 +11:00
Comfy Org PR Bot
577af51ff8 1.15.10 (#3319)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-03 22:31:57 -04:00
Chenlei Hu
df7c7383e2 Only show reroute migration dialog when native reroute is not present (#3318) 2025-04-03 22:08:40 -04:00
Comfy Org PR Bot
1279f30f5a [chore] Update litegraph to 0.11.9 (#3316)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-03 14:49:35 -04:00
Benjamin Lu
9ab4b549c0 Enable double clicking keybind row to edit bind (#2924) (#3315)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-04-03 14:04:02 -04:00
Comfy Org PR Bot
10de4e5445 1.15.9 (#3314)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-02 19:35:18 -04:00
Chenlei Hu
30420f2c0a [TS] Add null checks to widgetInputs.ts (#3312) 2025-04-02 15:48:46 -04:00
Chenlei Hu
39c3a57c11 [Cleanup] Remove handling of legacy slot widget config (#3311) 2025-04-02 13:58:06 -04:00
Chenlei Hu
6d09b7165f [TS] Fix event type for executing listener (#3310) 2025-04-02 11:16:36 -04:00
Chenlei Hu
8fc6840434 [Bug] Fix progress bar display on last output node (#3309) 2025-04-02 11:07:25 -04:00
Chenlei Hu
db575425fe [nit] Show error message from response (#3308) 2025-04-02 10:39:10 -04:00
Laurent Erignoux
ccb71bf1a3 Ensures we clean missing local storage comfy UserId (#3289) 2025-04-01 22:42:00 -04:00
Chenlei Hu
733d71aaac [Refactor] Split node constructor logic (#3307) 2025-04-01 20:48:32 -04:00
Comfy Org PR Bot
e059b9b82f 1.15.8 (#3306)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-01 16:47:22 -04:00
Chenlei Hu
cfaf769a65 [TS] Properly type slot widget (#3305) 2025-04-01 15:13:46 -04:00
Chenlei Hu
b80e0e1a3c [Performance] Avoid layout thrashing (#3302) 2025-04-01 14:05:06 -04:00
Chenlei Hu
7b7d9905a7 Expose currently active color palette (#3304) 2025-04-01 14:04:57 -04:00
Chenlei Hu
594fc5945c Fix workflow persistence (#3303) 2025-04-01 13:52:04 -04:00
Chenlei Hu
e5abf765bd [Performance] Avoid per-frame workflow persistence (#3301) 2025-04-01 11:31:28 -04:00
dependabot[bot]
712c127bb5 Bump vite from 5.4.15 to 5.4.16 (#3295)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 00:11:55 -04:00
Chenlei Hu
854501ef27 Show object widget values as string on missing nodes (#3294)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-31 20:55:50 -04:00
Comfy Org PR Bot
aea4493b4d 1.15.7 (#3293)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-31 20:07:19 -04:00
Chenlei Hu
df47226fd4 Add Reroute SplineOffset setting (#3292)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-31 20:05:38 -04:00
Chenlei Hu
f26f5f25bb Add widget to node with missing definition (#3291)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-31 14:23:44 -04:00
Comfy Org PR Bot
284902cabe 1.15.6 (#3287)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-30 21:48:18 -04:00
Chenlei Hu
58dec5ea42 Add reroute migration toast (#3286)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-30 21:48:10 -04:00
Chenlei Hu
7e76665a22 Revert "Migrate legacy reroute to litegraph native reroute (#3151)" (#3285)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-30 21:19:33 -04:00
Chenlei Hu
cb06d96930 [Refactor] Use NodeSlot.hasErrors API (#3284)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-30 20:10:28 -04:00
Benjamin Lu
b01ddb6aff Make entire result image preview clickable (#3279)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-03-30 19:06:29 -04:00
Chenlei Hu
10bed33383 [Refactor] Use LGraphNode.progress API (#3281) 2025-03-30 18:13:31 -04:00
Comfy Org PR Bot
a57e60d60a [chore] Update litegraph to 0.11.7 (#3280)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-30 17:59:20 -04:00
Chenlei Hu
8c789bd05d [Refactor] Use litegraph LGraphNode.strokeStyles API (#3278) 2025-03-30 12:05:45 -04:00
Chenlei Hu
28def833f9 [TS] Fix node constructor signature (#3276) 2025-03-29 20:55:11 -04:00
Chenlei Hu
fcc22f06ac [Refactor/TS] Simplify node filter logic (#3275) 2025-03-29 13:00:18 -04:00
Chenlei Hu
3922a5882b [Refactor] Extract fuse search class as a separate file (#3274) 2025-03-29 12:04:29 -04:00
Comfy Org PR Bot
4a40e83b98 1.15.5 (#3268)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-28 21:20:27 -04:00
Chenlei Hu
21e0caa1b1 [Bug] Fix undo of colorization via selection toolbox (#3267) 2025-03-28 21:00:43 -04:00
Chenlei Hu
04af8cda4d Use new error dialog for queue prompt errors (#3266)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-28 13:51:00 -04:00
Chenlei Hu
504b717575 [Refactor] Unify error dialog component (#3265)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-28 11:53:29 -04:00
Chenlei Hu
62fdcd4949 [Refactor] Extract error report generation logic (#3263) 2025-03-28 10:50:27 -04:00
Yiximail
cb7adaef9b maskeditor pen input support for windows (#3201) 2025-03-28 13:58:53 +01:00
Comfy Org PR Bot
6aad5222ab 1.15.4 (#3261)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-27 22:23:15 -04:00
Chenlei Hu
690326c374 [Reroute] Migrate floating link (#3260) 2025-03-27 22:13:16 -04:00
Comfy Org PR Bot
25ce267b2e [chore] Update litegraph to 0.11.5 (#3258)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-28 09:09:32 +11:00
Comfy Org PR Bot
78e3a20773 [chore] Update litegraph to 0.11.4 (#3257)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-27 14:24:19 -04:00
Chenlei Hu
56dbcbbd22 [Bug] Fix convert dom widget placeholder render (#3256) 2025-03-27 14:09:30 -04:00
Christian Byrne
4bfc8e9e33 [Manager] Fetch lists of node packs in single request (#3250) 2025-03-27 11:49:05 -04:00
Chenlei Hu
6e72207927 [Bug] Fix this binding in useChainCallback (#3252) 2025-03-27 11:38:52 -04:00
Christian Byrne
71968ae133 Translate action history items (#3249)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-27 11:13:24 -04:00
88 changed files with 3058 additions and 1347 deletions

View File

@@ -51,7 +51,10 @@
0.85,
false,
false,
""
"",
{
"foo": "bar"
}
]
}
],

View File

@@ -32,7 +32,7 @@ test.describe('Keybindings', () => {
})
await comfyPage.executeCommand('TestCommand')
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
expect(await comfyPage.getToastErrorCount()).toBe(1)
})
test('Should handle async command errors', async ({ comfyPage }) => {
@@ -45,6 +45,6 @@ test.describe('Keybindings', () => {
})
await comfyPage.executeCommand('TestCommand')
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
expect(await comfyPage.getToastErrorCount()).toBe(1)
})
})

View File

@@ -323,7 +323,21 @@ test.describe('Error dialog', () => {
await comfyPage.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.error-dialog-content')
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
test('Should display an error dialog when prompt execution fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window['app']
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
await app.queuePrompt(0)
})
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
})

View File

@@ -0,0 +1,20 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Execution', () => {
test('Report error on unconnected slot', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await comfyPage.clickEmptySpace()
await comfyPage.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -39,6 +39,10 @@ test.describe('Reroute Node', () => {
})
test.describe('LiteGraph Native Reroute Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
})
test('loads from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('reroute/native_reroute')
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -246,5 +246,24 @@ test.describe('Selection Toolbox', () => {
)
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
})
test('colorization via color picker can be undone', async ({
comfyPage
}) => {
// Select a node and color it
await comfyPage.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
// Undo the colorization
await comfyPage.page.keyboard.press('Control+Z')
await comfyPage.nextFrame()
// Node should be uncolored again
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(await selectedNode.getProperty('color')).toBeUndefined()
})
})
})

View File

@@ -192,3 +192,19 @@ test.describe('Load audio widget', () => {
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
})
})
test.describe('Unserialized widgets', () => {
test('Unserialized widgets values do not mark graph as modified', async ({
comfyPage
}) => {
// Add workflow w/ LoadImage node, which contains file upload and image preview widgets (not serialized)
await comfyPage.loadWorkflow('widgets/load_image_widget')
// Move mouse and click to trigger the `graphEqual` check in `changeTracker.ts`
await comfyPage.page.mouse.move(10, 10)
await comfyPage.page.mouse.click(10, 10)
// Expect the graph to not be modified
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
})
})

20
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.15.3",
"version": "1.15.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.15.3",
"version": "1.15.11",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.11.3",
"@comfyorg/litegraph": "^0.11.10",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -77,7 +77,7 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.15",
"vite": "^5.4.16",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
@@ -478,9 +478,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.11.3.tgz",
"integrity": "sha512-OEZJMFbC4WwsIW+w42MA8cwMY/Uu+OL5tQfvdhrlIj51G/TZv1yvU7tNPeu0gZM24Vm5rzF9IxXF8aajD9BIlw==",
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.11.10.tgz",
"integrity": "sha512-3kMndikjaJSJ0NDQyueOKJKRI7GwThMIbdLSdPREnHiyCPpNusNev7bmzP4XvlzuLppmhH7dEwzBTWTQtQVRyA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -12559,9 +12559,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.15",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.15.3",
"version": "1.15.11",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -60,7 +60,7 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.15",
"vite": "^5.4.16",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
@@ -71,7 +71,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.11.3",
"@comfyorg/litegraph": "^0.11.10",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -17,7 +17,9 @@ const TITLE_SUFFIX = ' - ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
executionStore.isIdle
? ''
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const settingStore = useSettingStore()
@@ -41,7 +43,7 @@ const workflowNameText = computed(() => {
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${executionStore.executingNodeProgress}%] ${executionStore.executingNode.type}`
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)

View File

@@ -1,80 +1,159 @@
<template>
<div class="error-dialog-content flex flex-col gap-4">
<div class="comfy-error-report flex flex-col gap-4">
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="title"
:message="errorMessage"
:message="error.exceptionMessage"
/>
<pre
class="stack-trace p-5 text-neutral-400 text-xs max-h-[50vh] overflow-auto bg-black/20"
>
{{ stackTrace }}
</pre>
<template v-if="extensionFile">
<template v-if="error.extensionFile">
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
<br />
<span class="font-bold">{{ extensionFile }}</span>
<span class="font-bold">{{ error.extensionFile }}</span>
</template>
<Button
v-show="!sendReportOpen"
text
fluid
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
<div class="flex gap-2 justify-center">
<Button
v-show="!reportOpen"
text
:label="$t('g.showReport')"
@click="showReport"
/>
<Button
v-show="!sendReportOpen"
text
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
</div>
<template v-if="reportOpen">
<Divider />
<ScrollPanel class="w-full h-[400px] max-w-[80vw]">
<pre class="whitespace-pre-wrap break-words">{{ reportContent }}</pre>
</ScrollPanel>
<Divider />
</template>
<ReportIssuePanel
v-if="sendReportOpen"
:error-type="errorType"
:extra-fields="[
{
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
getData: () => stackTrace
}
]"
:title="$t('issueReport.submitErrorReport')"
:error-type="error.reportType ?? 'unknownError'"
:extra-fields="[stackTraceField]"
:tags="{
exceptionMessage: errorMessage,
extensionFile: extensionFile ?? 'UNKNOWN'
exceptionMessage: error.exceptionMessage,
nodeType: error.nodeType ?? 'UNKNOWN'
}"
:title="t('issueReport.submitErrorReport')"
/>
<div class="flex gap-4 justify-end">
<FindIssueButton
:errorMessage="error.exceptionMessage"
:repoOwner="repoOwner"
:repoName="repoName"
/>
<Button
v-if="reportOpen"
:label="$t('g.copyToClipboard')"
icon="pi pi-copy"
@click="copyReportToClipboard"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ReportField } from '@/types/issueReportTypes'
import {
type ErrorReportData,
generateErrorReport
} from '@/utils/errorReportUtil'
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const { t } = useI18n()
const {
title: _title,
errorMessage,
stackTrace: _stackTrace,
extensionFile,
errorType = 'frontendError'
} = defineProps<{
title?: string
errorMessage: string
stackTrace?: string
extensionFile?: string
errorType?: string
const { error } = defineProps<{
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
/**
* The type of error report to submit.
* @default 'unknownError'
*/
reportType?: string
/**
* The file name of the extension that caused the error.
*/
extensionFile?: string
}
}>()
const title = computed(() => _title ?? t('errorDialog.defaultTitle'))
const stackTrace = computed(() => _stackTrace ?? t('errorDialog.noStackTrace'))
const repoOwner = 'comfyanonymous'
const repoName = 'ComfyUI'
const reportContent = ref('')
const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const sendReportOpen = ref(false)
function showSendReport() {
const showSendReport = () => {
sendReportOpen.value = true
}
const toast = useToast()
const { t } = useI18n()
const systemStatsStore = useSystemStatsStore()
const title = computed<string>(
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
)
const stackTraceField = computed<ReportField>(() => {
return {
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
getData: () => error.traceback
}
})
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
try {
const [logs] = await Promise.all([api.getLogs()])
reportContent.value = generateErrorReport({
systemStats: systemStatsStore.systemStats!,
serverLogs: logs,
workflow: app.graph.serialize(),
exceptionType: error.exceptionType,
exceptionMessage: error.exceptionMessage,
traceback: error.traceback,
nodeId: error.nodeId,
nodeType: error.nodeType
})
} catch (error) {
console.error('Error fetching logs:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
})
}
})
const { copyToClipboard } = useCopyToClipboard()
const copyReportToClipboard = async () => {
await copyToClipboard(reportContent.value)
}
</script>

View File

@@ -1,196 +0,0 @@
<template>
<NoResultsPlaceholder
icon="pi pi-exclamation-circle"
:title="props.error.node_type"
:message="props.error.exception_message"
/>
<div class="comfy-error-report">
<div class="flex gap-2 justify-center">
<Button
v-show="!reportOpen"
text
:label="$t('g.showReport')"
@click="showReport"
/>
<Button
v-show="!sendReportOpen"
text
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
</div>
<template v-if="reportOpen">
<Divider />
<ScrollPanel style="width: 100%; height: 400px; max-width: 80vw">
<pre class="wrapper-pre">{{ reportContent }}</pre>
</ScrollPanel>
<Divider />
</template>
<ReportIssuePanel
v-if="sendReportOpen"
:title="$t('issueReport.submitErrorReport')"
error-type="graphExecutionError"
:extra-fields="[stackTraceField]"
:tags="{
exceptionMessage: props.error.exception_message,
nodeType: props.error.node_type
}"
/>
<div class="action-container">
<FindIssueButton
:errorMessage="props.error.exception_message"
:repoOwner="repoOwner"
:repoName="repoName"
/>
<Button
v-if="reportOpen"
:label="$t('g.copyToClipboard')"
icon="pi pi-copy"
@click="copyReportToClipboard"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ExecutionErrorWsMessage, SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { ReportField } from '@/types/issueReportTypes'
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const props = defineProps<{
error: ExecutionErrorWsMessage
}>()
const repoOwner = 'comfyanonymous'
const repoName = 'ComfyUI'
const reportContent = ref('')
const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const sendReportOpen = ref(false)
const showSendReport = () => {
sendReportOpen.value = true
}
const toast = useToast()
const { t } = useI18n()
const stackTraceField = computed<ReportField>(() => {
return {
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
getData: () => props.error.traceback?.join('\n')
}
})
onMounted(async () => {
try {
const [systemStats, logs] = await Promise.all([
api.getSystemStats(),
api.getLogs()
])
generateReport(systemStats, logs)
} catch (error) {
console.error('Error fetching system stats or logs:', error)
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to fetch system information',
life: 5000
})
}
})
const generateReport = (systemStats: SystemStats, logs: string) => {
// The default JSON workflow has about 3000 characters.
const MAX_JSON_LENGTH = 20000
const workflowJSONString = JSON.stringify(app.graph.serialize())
const workflowText =
workflowJSONString.length > MAX_JSON_LENGTH
? 'Workflow too large. Please manually upload the workflow from local file system.'
: workflowJSONString
reportContent.value = `
# ComfyUI Error Report
## Error Details
- **Node ID:** ${props.error.node_id}
- **Node Type:** ${props.error.node_type}
- **Exception Type:** ${props.error.exception_type}
- **Exception Message:** ${props.error.exception_message}
## Stack Trace
\`\`\`
${props.error.traceback.join('\n')}
\`\`\`
## System Information
- **ComfyUI Version:** ${systemStats.system.comfyui_version}
- **Arguments:** ${systemStats.system.argv.join(' ')}
- **OS:** ${systemStats.system.os}
- **Python Version:** ${systemStats.system.python_version}
- **Embedded Python:** ${systemStats.system.embedded_python}
- **PyTorch Version:** ${systemStats.system.pytorch_version}
## Devices
${systemStats.devices
.map(
(device) => `
- **Name:** ${device.name}
- **Type:** ${device.type}
- **VRAM Total:** ${device.vram_total}
- **VRAM Free:** ${device.vram_free}
- **Torch VRAM Total:** ${device.torch_vram_total}
- **Torch VRAM Free:** ${device.torch_vram_free}
`
)
.join('\n')}
## Logs
\`\`\`
${logs}
\`\`\`
## Attached Workflow
Please make sure that workflow does not contain any sensitive information such as API keys or passwords.
\`\`\`
${workflowText}
\`\`\`
## Additional Context
(Please add any additional context or steps to reproduce the error here)
`
}
const { copyToClipboard } = useCopyToClipboard()
const copyReportToClipboard = async () => {
await copyToClipboard(reportContent.value)
}
</script>
<style scoped>
.comfy-error-report {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-container {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.wrapper-pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -188,59 +188,52 @@ const {
isLoading: isLoadingWorkflow
} = useWorkflowPacks()
const getInstalledResults = () => {
if (isEmptySearch.value) {
startFetchInstalled()
return installedPacks.value
} else {
return filterInstalledPack(searchResults.value)
}
}
const getInWorkflowResults = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
return workflowPacks.value
} else {
return filterWorkflowPack(searchResults.value)
}
}
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
const setMissingPacks = () => {
displayPacks.value = filterMissingPacks(workflowPacks.value)
}
const isInstalledTab = computed(
() => selectedTab.value?.id === ManagerTab.Installed
)
const isMissingTab = computed(
() => selectedTab.value?.id === ManagerTab.Missing
)
const isWorkflowTab = computed(
() => selectedTab.value?.id === ManagerTab.Workflow
)
const isAllTab = computed(() => selectedTab.value?.id === ManagerTab.All)
const getMissingPacks = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
whenever(() => workflowPacks.value.length, setMissingPacks, {
immediate: true,
once: true
})
return filterMissingPacks(workflowPacks.value)
watch([isInstalledTab, installedPacks], () => {
if (!isInstalledTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterInstalledPack(searchResults.value)
} else if (!installedPacks.value.length) {
startFetchInstalled()
} else {
return filterMissingPacks(filterWorkflowPack(searchResults.value))
displayPacks.value = installedPacks.value
}
}
})
const onTabChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = getInstalledResults()
break
case ManagerTab.Workflow:
displayPacks.value = getInWorkflowResults()
break
case ManagerTab.Missing:
displayPacks.value = getMissingPacks()
break
default:
displayPacks.value = searchResults.value
watch([isMissingTab, isWorkflowTab, workflowPacks], () => {
if (!isWorkflowTab.value && !isMissingTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = isMissingTab.value
? filterMissingPacks(filterWorkflowPack(searchResults.value))
: filterWorkflowPack(searchResults.value)
} else if (!workflowPacks.value.length) {
startFetchWorkflowPacks()
} else {
displayPacks.value = isMissingTab.value
? filterMissingPacks(workflowPacks.value)
: workflowPacks.value
}
}
})
watch([isAllTab, searchResults], () => {
if (!isAllTab.value) return
displayPacks.value = searchResults.value
})
const onResultsChange = () => {
switch (selectedTab.value?.id) {
@@ -260,7 +253,6 @@ const onResultsChange = () => {
}
}
whenever(selectedTab, onTabChange)
watch(searchResults, onResultsChange, { flush: 'pre' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)

View File

@@ -17,6 +17,7 @@
:pt="{
header: 'px-0'
}"
@rowDblclick="editKeybinding($event.data)"
>
<Column field="actions" header="">
<template #body="slotProps">

View File

@@ -19,11 +19,15 @@
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const tabs = [{ label: 'Installation Queue' }, { label: 'Failed (0)' }]
const { t } = useI18n()
const tabs = [
{ label: t('manager.installationQueue') },
{ label: t('manager.failed', { count: 0 }) }
]
</script>

View File

@@ -37,6 +37,7 @@
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -61,13 +62,15 @@ import { usePaste } from '@/composables/usePaste'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n } from '@/i18n'
import { api } from '@/scripts/api'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -80,6 +83,7 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -158,6 +162,55 @@ watch(
}
)
// Update the progress of the executing node
watch(
() =>
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
NodeId | null,
number | null
],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
node.progress = executingNodeProgress ?? undefined
} else {
node.progress = undefined
}
}
}
)
// Update node slot errors
watch(
() => executionStore.lastNodeErrors,
(lastNodeErrors) => {
const removeSlotError = (node: LGraphNode) => {
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
}
for (const node of comfyApp.graph.nodes) {
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
for (const error of nodeErrors.errors) {
if (error.extra_info && error.extra_info.input_name) {
const inputIndex = node.findInputSlot(error.extra_info.input_name)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
}
}
}
comfyApp.canvas.draw(true, true)
}
)
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
@@ -188,7 +241,20 @@ onMounted(async () => {
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
await loadCustomNodesI18n()
await settingStore.loadSettingValues()
try {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log(
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
)
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
} else {
throw error
}
}
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})

View File

@@ -47,6 +47,7 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { getItemsColorOption } from '@/utils/litegraphUtil'
@@ -54,6 +55,7 @@ import { getItemsColorOption } from '@/utils/litegraphUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const workflowStore = useWorkflowStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
@@ -108,6 +110,7 @@ const applyColor = (colorOption: ColorOption | null) => {
canvasStore.canvas?.setDirty(true, true)
currentColorOption.value = canvasColorOption
showColorPicker.value = false
workflowStore.activeWorkflow?.changeTracker.checkState()
}
const currentColorOption = ref<CanvasColorOption | null>(null)

View File

@@ -17,7 +17,6 @@
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
@@ -63,9 +62,9 @@ const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) return
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea

View File

@@ -60,12 +60,17 @@
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
<SearchFilterChip
v-if="Array.isArray(value) && value.length === 2"
:key="`${value[0].id}-${value[1]}`"
@remove="onRemoveFilter($event, value as FilterAndValue)"
:text="value[1]"
:badge="value[0].invokeSequence.toUpperCase()"
:badge-class="value[0].invokeSequence + '-badge'"
v-if="value.filterDef && value.value"
:key="`${value.filterDef.id}-${value.value}`"
@remove="
onRemoveFilter(
$event,
value as FuseFilterWithValue<ComfyNodeDefImpl, string>
)
"
:text="value.value"
:badge="value.filterDef.invokeSequence.toUpperCase()"
:badge-class="value.filterDef.invokeSequence + '-badge'"
/>
</template>
</AutoCompletePlus>
@@ -82,13 +87,13 @@ import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import SearchFilterChip from '../common/SearchFilterChip.vue'
@@ -100,7 +105,7 @@ const enableNodePreview = computed(() =>
)
const { filters, searchLimit = 64 } = defineProps<{
filters: FilterAndValue[]
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
searchLimit?: number
}>()
@@ -139,11 +144,16 @@ const reFocusInput = () => {
}
onMounted(reFocusInput)
const onAddFilter = (filterAndValue: FilterAndValue) => {
const onAddFilter = (
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
nodeSearchFilterVisible.value = false
emit('addFilter', filterAndValue)
}
const onRemoveFilter = (event: Event, filterAndValue: FilterAndValue) => {
const onRemoveFilter = (
event: Event,
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
event.stopPropagation()
event.preventDefault()
emit('removeFilter', filterAndValue)

View File

@@ -46,13 +46,13 @@ import Dialog from 'primevue/dialog'
import { computed, ref, toRaw, watchEffect } from 'vue'
import { useLitegraphService } from '@/services/litegraphService'
import { FilterAndValue } from '@/services/nodeSearchService'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { ConnectingLinkImpl } from '@/types/litegraphTypes'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -71,11 +71,13 @@ const getNewNodeLocation = (): Vector2 => {
.originalEvent
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = ref<FilterAndValue[]>([])
const addFilter = (filter: FilterAndValue) => {
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
nodeFilters.value.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
const removeFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter)
)
@@ -136,13 +138,16 @@ const showNewSearchBox = (e: LiteGraphCanvasEvent) => {
return
}
const firstLink = ConnectingLinkImpl.createFromPlainObject(links[0])
const filter = nodeDefStore.nodeSearchService.getFilterById(
firstLink.releaseSlotType
)
// @ts-expect-error fixme ts strict error
const dataType = firstLink.type.toString()
// @ts-expect-error fixme ts strict error
addFilter([filter, dataType])
const filter =
firstLink.releaseSlotType === 'input'
? nodeDefStore.nodeSearchService.inputTypeFilter
: nodeDefStore.nodeSearchService.outputTypeFilter
const dataType = firstLink.type?.toString() ?? ''
addFilter({
filterDef: filter,
value: dataType
})
}
visible.value = true

View File

@@ -27,11 +27,11 @@ import Select from 'primevue/select'
import SelectButton from 'primevue/selectbutton'
import { computed, onMounted, ref } from 'vue'
import { type FilterAndValue, NodeFilter } from '@/services/nodeSearchService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
const filters = computed(() => nodeDefStore.nodeSearchService.nodeFilters)
const selectedFilter = ref<NodeFilter>()
const selectedFilter = ref<FuseFilter<ComfyNodeDefImpl, string>>()
const filterValues = computed(() => selectedFilter.value?.fuseSearch.data ?? [])
const selectedFilterValue = ref<string>('')
@@ -43,7 +43,10 @@ onMounted(() => {
})
const emit = defineEmits<{
(event: 'addFilter', filterAndValue: FilterAndValue): void
(
event: 'addFilter',
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
): void
}>()
const updateSelectedFilterValue = () => {
@@ -54,10 +57,13 @@ const updateSelectedFilterValue = () => {
}
const submit = () => {
emit('addFilter', [
selectedFilter.value,
selectedFilterValue.value
] as FilterAndValue)
if (!selectedFilter.value) {
return
}
emit('addFilter', {
filterDef: selectedFilter.value,
value: selectedFilterValue.value
})
}
</script>

View File

@@ -76,7 +76,6 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { FilterAndValue } from '@/services/nodeSearchService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import {
ComfyNodeDefImpl,
@@ -85,6 +84,7 @@ import {
} from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
@@ -150,8 +150,9 @@ const filteredRoot = computed<TreeNode | null>(() => {
}
return buildNodeDefTree(filteredNodeDefs.value)
})
const filters: Ref<Array<SearchFilter & { filter: FilterAndValue<string> }>> =
ref([])
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])
const handleSearch = (query: string) => {
// Don't apply a min length filter because it does not make sense in
// multi-byte languages like Chinese, Japanese, Korean, etc.
@@ -161,7 +162,7 @@ const handleSearch = (query: string) => {
return
}
const f = filters.value.map((f) => f.filter as FilterAndValue<string>)
const f = filters.value.map((f) => f.filter)
filteredNodeDefs.value = nodeDefStore.nodeSearchService.searchNode(
query,
f,
@@ -179,12 +180,14 @@ const handleSearch = (query: string) => {
})
}
const onAddFilter = (filterAndValue: FilterAndValue) => {
const onAddFilter = (
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
filters.value.push({
filter: filterAndValue,
badge: filterAndValue[0].invokeSequence.toUpperCase(),
badgeClass: filterAndValue[0].invokeSequence + '-badge',
text: filterAndValue[1],
badge: filterAndValue.filterDef.invokeSequence.toUpperCase(),
badgeClass: filterAndValue.filterDef.invokeSequence + '-badge',
text: filterAndValue.value,
id: +new Date()
})

View File

@@ -1,5 +1,9 @@
<template>
<div class="result-container" ref="resultContainer">
<div
class="result-container"
ref="resultContainer"
@click="handlePreviewClick"
>
<ComfyImage
v-if="result.isImage"
:src="result.url"
@@ -12,20 +16,10 @@
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
</div>
<div v-if="result.supportsPreview" class="preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
@@ -48,6 +42,12 @@ const imageFit = computed<string>(() =>
settingStore.get('Comfy.Queue.ImageFit')
)
const handlePreviewClick = () => {
if (props.result.supportsPreview) {
emit('preview', props.result)
}
}
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value?.querySelectorAll('img').forEach((img) => {
@@ -67,22 +67,11 @@ onMounted(() => {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease;
}
.preview-mask {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
.result-container:hover .preview-mask {
opacity: 1;
.result-container:hover {
transform: scale(1.02);
}
</style>

View File

@@ -231,6 +231,13 @@ const cancelledWithoutResults = computed(() => {
align-items: center;
width: 100%;
z-index: 1;
pointer-events: none; /* Allow clicks to pass through this div */
}
/* Make individual controls clickable again by restoring pointer events */
.task-item-details .tag-wrapper,
.task-item-details button {
pointer-events: auto;
}
.task-node-link {

View File

@@ -0,0 +1,46 @@
<template>
<Toast group="reroute-migration">
<template #message>
<div class="flex flex-col items-start flex-auto">
<div class="font-medium text-lg my-4">
{{ t('toastMessages.migrateToLitegraphReroute') }}
</div>
<Button
class="self-end"
size="small"
:label="t('g.migrate')"
severity="warn"
@click="migrateToLitegraphReroute"
/>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import type { WorkflowJSON04 } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { useWorkflowStore } from '@/stores/workflowStore'
import { migrateLegacyRerouteNodes } from '@/utils/migration/migrateReroute'
const { t } = useI18n()
const toast = useToast()
const workflowStore = useWorkflowStore()
const migrateToLitegraphReroute = () => {
const workflowJSON = app.serializeGraph() as unknown as WorkflowJSON04
const migratedWorkflowJSON = migrateLegacyRerouteNodes(workflowJSON)
app.loadGraphData(
migratedWorkflowJSON,
false,
false,
workflowStore.activeWorkflow
)
toast.removeGroup('reroute-migration')
}
</script>

View File

@@ -0,0 +1,36 @@
import type { LGraphCanvas, Vector2 } from '@comfyorg/litegraph'
import { useElementBounding } from '@vueuse/core'
/**
* Convert between canvas and client positions
* @param canvasElement - The canvas element
* @param lgCanvas - The litegraph canvas
* @returns The canvas position conversion functions
*/
export const useCanvasPositionConversion = (
canvasElement: Parameters<typeof useElementBounding>[0],
lgCanvas: LGraphCanvas
) => {
const { left, top } = useElementBounding(canvasElement)
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] - left.value) / scale + offset[0],
(pos[1] - top.value) / scale + offset[1]
]
}
const canvasPosToClientPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] + offset[0]) * scale + left.value,
(pos[1] + offset[1]) * scale + top.value
]
}
return {
clientPosToCanvasPos,
canvasPosToClientPos
}
}

View File

@@ -5,12 +5,15 @@
* @param callbacks - The callbacks to chain.
* @returns A new callback that chains the original callback with the callbacks.
*/
export const useChainCallback = <T extends (...args: any[]) => void>(
export const useChainCallback = <
O,
T extends (this: O, ...args: any[]) => void
>(
originalCallback: T | undefined,
...callbacks: ((...args: Parameters<T>) => void)[]
...callbacks: ((this: O, ...args: Parameters<T>) => void)[]
) => {
return (...args: Parameters<T>) => {
originalCallback?.(...args)
callbacks.forEach((callback) => callback(...args))
return function (this: O, ...args: Parameters<T>) {
originalCallback?.call(this, ...args)
callbacks.forEach((callback) => callback.call(this, ...args))
}
}

View File

@@ -1,14 +1,8 @@
import { useAsyncState } from '@vueuse/core'
import { chunk } from 'lodash'
import { Ref, computed, isRef, ref } from 'vue'
import { get, useAsyncState } from '@vueuse/core'
import { Ref } from 'vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_MAX_CONCURRENT = 6
type NodePack = components['schemas']['Node']
/**
* Handles fetching node packs from the registry given a list of node pack IDs
@@ -17,54 +11,25 @@ export const useNodePacks = (
packsIds: string[] | Ref<string[]>,
options: UseNodePacksOptions = {}
) => {
const { immediate = false, maxConcurrent = DEFAULT_MAX_CONCURRENT } = options
const { getPackById } = useComfyRegistryStore()
const { immediate = false } = options
const { getPacksByIds } = useComfyRegistryStore()
const nodePacks = ref<NodePack[]>([])
const processedIds = ref<Set<string>>(new Set())
const fetchPacks = () => getPacksByIds.call(get(packsIds).filter(Boolean))
const queuedPackIds = isRef(packsIds) ? packsIds : ref<string[]>(packsIds)
const remainingIds = computed(() =>
queuedPackIds.value?.filter((id) => !processedIds.value.has(id))
)
const chunks = computed(() =>
remainingIds.value?.length ? chunk(remainingIds.value, maxConcurrent) : []
)
const fetchPack = (id: Parameters<typeof getPackById.call>[0]) =>
id ? getPackById.call(id) : null
const toRequestBatch = async (ids: string[]) =>
Promise.all(ids.map(fetchPack))
const isValidResponse = (response: NodePack | null) => response !== null
const fetchPacks = async () => {
for (const chunk of chunks.value) {
const resolvedChunk = await toRequestBatch(chunk)
chunk.forEach((id) => processedIds.value.add(id))
if (!resolvedChunk) continue
nodePacks.value.push(...resolvedChunk.filter(isValidResponse))
}
}
const { isReady, isLoading, error, execute } = useAsyncState(
fetchPacks,
null,
{
immediate
}
)
const clear = () => {
queuedPackIds.value = []
isReady.value = false
isLoading.value = false
}
const {
isReady,
isLoading,
error,
execute,
state: nodePacks
} = useAsyncState(fetchPacks, [], {
immediate
})
const cleanup = () => {
getPackById.cancel()
clear()
getPacksByIds.cancel()
isReady.value = false
isLoading.value = false
}
return {

View File

@@ -116,4 +116,10 @@ export const useLitegraphSettings = () => {
'LiteGraph.ContextMenu.Scaling'
)
})
watchEffect(() => {
LiteGraph.Reroute.maxSplineOffset = settingStore.get(
'LiteGraph.Reroute.SplineOffset'
)
})
}

View File

@@ -1,4 +1,4 @@
import { computed, watch, watchEffect } from 'vue'
import { computed, watch } from 'vue'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -70,15 +70,16 @@ export function useWorkflowPersistence() {
}
// Setup watchers
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.key)
watch(
() => workflowStore.activeWorkflow?.key,
(activeWorkflowKey) => {
if (!activeWorkflowKey) return
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
})
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Restore workflow tabs states

View File

@@ -752,5 +752,17 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true,
versionAdded: '1.10.5'
},
{
id: 'LiteGraph.Reroute.SplineOffset',
name: 'Reroute spline offset',
tooltip: 'The bezier control point offset from the reroute centre point',
type: 'slider',
defaultValue: 20,
attrs: {
min: 0,
max: 400
},
versionAdded: '1.15.7'
}
]

View File

@@ -357,6 +357,78 @@ export class GroupNodeConfig {
output_is_list: []
})
return def
} else if (node.type === 'Reroute') {
// @ts-expect-error fixme ts strict error
const linksTo = this.linksTo[node.index]
// @ts-expect-error fixme ts strict error
if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) {
// Being used internally
return null
}
let config = {}
let rerouteType = '*'
if (linksFrom) {
for (const [, , id, slot] of linksFrom['0']) {
const node = this.nodeData.nodes[id]
const input = node.inputs[slot]
if (rerouteType === '*') {
rerouteType = input.type
}
if (input.widget) {
// @ts-expect-error fixme ts strict error
const targetDef = globalDefs[node.type]
const targetWidget =
targetDef.input.required[input.widget.name] ??
targetDef.input.optional[input.widget.name]
const widget = [targetWidget[0], config]
const res = mergeIfValid(
{
// @ts-expect-error fixme ts strict error
widget
},
targetWidget,
false,
null,
widget
)
config = res?.customConfig ?? config
}
}
} else if (linksTo) {
const [id, slot] = linksTo['0']
rerouteType = this.nodeData.nodes[id].outputs[slot].type
} else {
// Reroute used as a pipe
for (const l of this.nodeData.links) {
if (l[2] === node.index) {
rerouteType = l[5]
break
}
}
if (rerouteType === '*') {
// Check for an external link
// @ts-expect-error fixme ts strict error
const t = this.externalFrom[node.index]?.[0]
if (t) {
rerouteType = t
}
}
}
// @ts-expect-error
config.forceInput = true
return {
input: {
required: {
[rerouteType]: [rerouteType, config]
}
},
output: [rerouteType],
output_name: [],
output_is_list: []
}
}
console.warn(
@@ -808,13 +880,22 @@ export class GroupNodeHandler {
link = { ...link }
const output = this.groupData.newToOldOutputMap[link.origin_slot]
let innerNode = this.innerNodes[output.node.index]
let l
while (innerNode?.type === 'Reroute') {
l = innerNode.getInputLink(0)
innerNode = innerNode.getInputNode(0)
}
if (!innerNode) {
return null
}
if (l && GroupNodeHandler.isGroupNode(innerNode)) {
return innerNode.updateLink(l)
}
link.origin_id = innerNode.id
link.origin_slot = output.slot
link.origin_slot = l?.origin_slot ?? output.slot
return link
}
@@ -1271,6 +1352,23 @@ export class GroupNodeHandler {
}
}
continue
} else if (innerNode.type === 'Reroute') {
const rerouteLinks = this.groupData.linksFrom[old.node.index]
if (rerouteLinks) {
for (const [_, , targetNodeId, targetSlot] of rerouteLinks['0']) {
const node = this.innerNodes[targetNodeId]
const input = node.inputs[targetSlot]
if (input.widget) {
const widget = node.widgets?.find(
// @ts-expect-error fixme ts strict error
(w) => w.name === input.widget.name
)
if (widget) {
widget.value = newValue
}
}
}
}
}
// @ts-expect-error fixme ts strict error
@@ -1313,6 +1411,30 @@ export class GroupNodeHandler {
return true
}
// @ts-expect-error fixme ts strict error
populateReroute(node, nodeId, map) {
if (node.type !== 'Reroute') return
const link = this.groupData.linksFrom[nodeId]?.[0]?.[0]
if (!link) return
const [, , targetNodeId, targetNodeSlot] = link
const targetNode = this.groupData.nodeData.nodes[targetNodeId]
const inputs = targetNode.inputs
const targetWidget = inputs?.[targetNodeSlot]?.widget
if (!targetWidget) return
const offset = inputs.length - (targetNode.widgets_values?.length ?? 0)
const v = targetNode.widgets_values?.[targetNodeSlot - offset]
if (v == null) return
const widgetName = Object.values(map)[0]
// @ts-expect-error fixme ts strict error
const widget = this.node.widgets.find((w) => w.name === widgetName)
if (widget) {
widget.value = v
}
}
populateWidgets() {
if (!this.node.widgets) return
@@ -1326,6 +1448,9 @@ export class GroupNodeHandler {
const widgets = Object.keys(map)
if (!node.widgets_values?.length) {
// special handling for populating values into reroutes
// this allows primitives connect to them to pick up the correct value
this.populateReroute(node, nodeId, map)
continue
}

View File

@@ -10,6 +10,7 @@ import './load3d'
import './maskeditor'
import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'
import './simpleTouchSupport'

View File

@@ -2382,7 +2382,6 @@ class BrushTool {
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
if (hardness === 1) {
console.log(sliderOpacity, opacity)
gradient.addColorStop(
0,
isErasing
@@ -4210,6 +4209,7 @@ class PanAndZoomManager {
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
penPointerIdList: number[] = []
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
@@ -4243,6 +4243,18 @@ class PanAndZoomManager {
this.updateCursorPosition(point)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
if (event.pointerType === 'pen')
this.penPointerIdList.push(event.pointerId)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
if (event.pointerType === 'pen') {
const index = this.penPointerIdList.indexOf(event.pointerId)
if (index > -1) this.penPointerIdList.splice(index, 1)
}
})
this.messageBroker.subscribe(
'handleTouchStart',
async (event: TouchEvent) => {
@@ -4281,7 +4293,10 @@ class PanAndZoomManager {
handleTouchStart(event: TouchEvent) {
event.preventDefault()
if ((event.touches[0] as any).touchType === 'stylus') return
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.messageBroker.publish('setBrushVisibility', false)
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
@@ -4310,7 +4325,9 @@ class PanAndZoomManager {
async handleTouchMove(event: TouchEvent) {
event.preventDefault()
if ((event.touches[0] as any).touchType === 'stylus') return
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.lastTwoFingerTap = 0
if (this.isTouchZooming && event.touches.length === 2) {
@@ -4361,23 +4378,17 @@ class PanAndZoomManager {
handleTouchEnd(event: TouchEvent) {
event.preventDefault()
if (
event.touches.length === 0 &&
(event.touches[0] as any).touchType === 'stylus'
) {
return
}
this.isTouchZooming = false
this.lastTouchMidPoint = { x: 0, y: 0 }
if (event.touches.length === 0) {
this.lastTouchPoint = { x: 0, y: 0 }
} else if (event.touches.length === 1) {
const lastTouch = event.touches[0]
// if all touches are removed, lastTouch will be null
if (lastTouch) {
this.lastTouchPoint = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
x: lastTouch.clientX,
y: lastTouch.clientY
}
} else {
this.isTouchZooming = false
this.lastTouchMidPoint = { x: 0, y: 0 }
}
}
@@ -4586,6 +4597,8 @@ class PanAndZoomManager {
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
this.penPointerIdList = []
await this.invalidatePanZoom()
}

View File

@@ -19,8 +19,7 @@ app.registerExtension({
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
isVirtualNode: boolean
constructor(title?: string) {
// @ts-expect-error fixme ts strict error
constructor(title: string) {
super(title)
if (!this.properties) {
this.properties = { text: '' }
@@ -58,8 +57,7 @@ app.registerExtension({
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
constructor(title?: string) {
// @ts-expect-error fixme ts strict error
constructor(title: string) {
super(title)
if (!this.properties) {
this.properties = { text: '' }

View File

@@ -0,0 +1,319 @@
import type { IContextMenuValue } from '@comfyorg/litegraph'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
// Node that allows you to redirect connections for cleaner graphs
app.registerExtension({
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
interface RerouteNode extends LGraphNode {
__outputType?: string
}
class RerouteNode extends LGraphNode {
static category: string | undefined
static defaultVisibility = false
constructor(title?: string) {
// @ts-expect-error fixme ts strict error
super(title)
if (!this.properties) {
this.properties = {}
}
this.properties.showOutputText = RerouteNode.defaultVisibility
this.properties.horizontal = false
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.onAfterGraphConfigured = function () {
requestAnimationFrame(() => {
// @ts-expect-error fixme ts strict error
this.onConnectionsChange(LiteGraph.INPUT, null, true, null)
})
}
this.onConnectionsChange = (type, _index, connected) => {
if (app.configuringGraph) return
// Prevent multiple connections to different types when we have no input
if (connected && type === LiteGraph.OUTPUT) {
// Ignore wildcard nodes as these will be updated to real types
const types = new Set(
// @ts-expect-error fixme ts strict error
this.outputs[0].links
.map((l) => app.graph.links[l].type)
.filter((t) => t !== '*')
)
if (types.size > 1) {
const linksToDisconnect = []
// @ts-expect-error fixme ts strict error
for (let i = 0; i < this.outputs[0].links.length - 1; i++) {
// @ts-expect-error fixme ts strict error
const linkId = this.outputs[0].links[i]
const link = app.graph.links[linkId]
linksToDisconnect.push(link)
}
for (const link of linksToDisconnect) {
const node = app.graph.getNodeById(link.target_id)
// @ts-expect-error fixme ts strict error
node.disconnectInput(link.target_slot)
}
}
}
// Find root input
let currentNode: LGraphNode | null = this
let updateNodes = []
let inputType = null
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = app.graph.links[linkId]
if (!link) return
const node = app.graph.getNodeById(link.origin_id)
// @ts-expect-error fixme ts strict error
const type = node.constructor.type
if (type === 'Reroute') {
if (node === this) {
// We've found a circle
currentNode.disconnectInput(link.target_slot)
currentNode = null
} else {
// Move the previous node
currentNode = node
}
} else {
// We've found the end
inputNode = currentNode
// @ts-expect-error fixme ts strict error
inputType = node.outputs[link.origin_slot]?.type ?? null
break
}
} else {
// This path has no input node
currentNode = null
break
}
}
// Find all outputs
const nodes: LGraphNode[] = [this]
let outputType = null
while (nodes.length) {
// @ts-expect-error fixme ts strict error
currentNode = nodes.pop()
const outputs =
// @ts-expect-error fixme ts strict error
(currentNode.outputs ? currentNode.outputs[0].links : []) || []
if (outputs.length) {
for (const linkId of outputs) {
const link = app.graph.links[linkId]
// When disconnecting sometimes the link is still registered
if (!link) continue
const node = app.graph.getNodeById(link.target_id)
// @ts-expect-error fixme ts strict error
const type = node.constructor.type
if (type === 'Reroute') {
// Follow reroute nodes
// @ts-expect-error fixme ts strict error
nodes.push(node)
updateNodes.push(node)
} else {
// We've found an output
const nodeOutType =
// @ts-expect-error fixme ts strict error
node.inputs &&
// @ts-expect-error fixme ts strict error
node.inputs[link?.target_slot] &&
// @ts-expect-error fixme ts strict error
node.inputs[link.target_slot].type
? // @ts-expect-error fixme ts strict error
node.inputs[link.target_slot].type
: null
if (
inputType &&
// @ts-expect-error fixme ts strict error
!LiteGraph.isValidConnection(inputType, nodeOutType)
) {
// The output doesnt match our input so disconnect it
// @ts-expect-error fixme ts strict error
node.disconnectInput(link.target_slot)
} else {
outputType = nodeOutType
}
}
}
} else {
// No more outputs for this path
}
}
const displayType = inputType || outputType || '*'
const color = LGraphCanvas.link_type_colors[displayType]
let widgetConfig
let widgetType
// Update the types of each node
for (const node of updateNodes) {
// If we dont have an input type we are always wildcard but we'll show the output type
// This lets you change the output link to a different type and all nodes will update
// @ts-expect-error fixme ts strict error
node.outputs[0].type = inputType || '*'
// @ts-expect-error fixme ts strict error
node.__outputType = displayType
// @ts-expect-error fixme ts strict error
node.outputs[0].name = node.properties.showOutputText
? displayType
: ''
// @ts-expect-error fixme ts strict error
node.setSize(node.computeSize())
// @ts-expect-error fixme ts strict error
for (const l of node.outputs[0].links || []) {
const link = app.graph.links[l]
if (link) {
link.color = color
if (app.configuringGraph) continue
const targetNode = app.graph.getNodeById(link.target_id)
// @ts-expect-error fixme ts strict error
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const config = getWidgetConfig(targetInput)
if (!widgetConfig) {
widgetConfig = config[1] ?? {}
widgetType = config[0]
}
const merged = mergeIfValid(targetInput, [
config[0],
widgetConfig
])
if (merged.customConfig) {
widgetConfig = merged.customConfig
}
}
}
}
}
for (const node of updateNodes) {
if (widgetConfig && outputType) {
// @ts-expect-error fixme ts strict error
node.inputs[0].widget = { name: 'value' }
// @ts-expect-error fixme ts strict error
setWidgetConfig(node.inputs[0], [
widgetType ?? displayType,
widgetConfig
])
} else {
// @ts-expect-error fixme ts strict error
setWidgetConfig(node.inputs[0], null)
}
}
if (inputNode) {
// @ts-expect-error fixme ts strict error
const link = app.graph.links[inputNode.inputs[0].link]
if (link) {
link.color = color
}
}
}
this.clone = function () {
const cloned = RerouteNode.prototype.clone.apply(this)
// @ts-expect-error fixme ts strict error
cloned.removeOutput(0)
// @ts-expect-error fixme ts strict error
cloned.addOutput(this.properties.showOutputText ? '*' : '', '*')
// @ts-expect-error fixme ts strict error
cloned.setSize(cloned.computeSize())
return cloned
}
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true
}
// @ts-expect-error fixme ts strict error
getExtraMenuOptions(_, options): IContextMenuValue[] {
options.unshift(
{
content:
(this.properties.showOutputText ? 'Hide' : 'Show') + ' Type',
callback: () => {
this.properties.showOutputText = !this.properties.showOutputText
if (this.properties.showOutputText) {
this.outputs[0].name =
this.__outputType || (this.outputs[0].type as string)
} else {
this.outputs[0].name = ''
}
this.setSize(this.computeSize())
app.graph.setDirtyCanvas(true, true)
}
},
{
content:
(RerouteNode.defaultVisibility ? 'Hide' : 'Show') +
' Type By Default',
callback: () => {
RerouteNode.setDefaultTextVisibility(
!RerouteNode.defaultVisibility
)
}
}
)
return []
}
computeSize(): [number, number] {
return [
this.properties.showOutputText && this.outputs && this.outputs.length
? Math.max(
75,
LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 +
40
)
: 75,
26
]
}
// @ts-expect-error fixme ts strict error
static setDefaultTextVisibility(visible) {
RerouteNode.defaultVisibility = visible
if (visible) {
localStorage['Comfy.RerouteNode.DefaultVisibility'] = 'true'
} else {
delete localStorage['Comfy.RerouteNode.DefaultVisibility']
}
}
}
// Load default visibility
RerouteNode.setDefaultTextVisibility(
!!localStorage['Comfy.RerouteNode.DefaultVisibility']
)
LiteGraph.registerNodeType(
'Reroute',
Object.assign(RerouteNode, {
title_mode: LiteGraph.NO_TITLE,
title: 'Reroute',
collapsable: false
})
)
RerouteNode.category = 'utils'
}
})

View File

@@ -1,4 +1,4 @@
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, LiteGraph, RenderShape } from '@comfyorg/litegraph'
import type {
IFoundSlot,
INodeInputSlot,
@@ -10,6 +10,7 @@ import type {
} from '@comfyorg/litegraph'
import type { CanvasMouseEvent } from '@comfyorg/litegraph/dist/types/events'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets'
@@ -34,13 +35,10 @@ const GET_CONFIG = Symbol()
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
// @ts-expect-error fixme ts strict error
controlValues: any[]
// @ts-expect-error fixme ts strict error
lastType: string
controlValues?: any[]
lastType?: string
static category: string
constructor(title?: string) {
// @ts-expect-error fixme ts strict error
constructor(title: string) {
super(title)
this.addOutput('connect to widget input', '*')
this.serialize_widgets = true
@@ -95,6 +93,7 @@ export class PrimitiveNode extends LGraphNode {
refreshComboInNode() {
const widget = this.widgets?.[0]
if (widget?.type === 'combo') {
// @ts-expect-error fixme ts strict error
widget.options.values = this.outputs[0].widget[GET_CONFIG]()[0]
// @ts-expect-error fixme ts strict error
@@ -150,7 +149,6 @@ export class PrimitiveNode extends LGraphNode {
}
}
// @ts-expect-error fixme ts strict error
onConnectOutput(
slot: number,
_type: string,
@@ -160,8 +158,8 @@ export class PrimitiveNode extends LGraphNode {
) {
// Fires before the link is made allowing us to reject it if it isn't valid
// No widget, we cant connect
if (!input.widget) {
if (!(input.type in ComfyWidgets)) return false
if (!input.widget && !(input.type in ComfyWidgets)) {
return false
}
if (this.outputs[slot].links?.length) {
@@ -172,6 +170,8 @@ export class PrimitiveNode extends LGraphNode {
}
return valid
}
return true
}
#onFirstConnection(recreating?: boolean) {
@@ -211,7 +211,6 @@ export class PrimitiveNode extends LGraphNode {
this.outputs[0].widget = widget
this.#createWidget(
// @ts-expect-error fixme ts strict error
widget[CONFIG] ?? config,
theirNode,
widget.name,
@@ -236,7 +235,6 @@ export class PrimitiveNode extends LGraphNode {
const [oldWidth, oldHeight] = this.size
let widget: IWidget | undefined
if (type in ComfyWidgets) {
// @ts-expect-error fixme ts strict error
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else {
// @ts-expect-error InputSpec is not typed correctly
@@ -259,7 +257,6 @@ export class PrimitiveNode extends LGraphNode {
control_value = 'fixed'
}
addValueControlWidgets(
// @ts-expect-error fixme ts strict error
this,
widget,
control_value as string,
@@ -267,9 +264,7 @@ export class PrimitiveNode extends LGraphNode {
inputData
)
let filter = this.widgets_values?.[2]
// @ts-expect-error fixme ts strict error
if (filter && this.widgets.length === 3) {
// @ts-expect-error change widget type from string to unknown
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter
}
}
@@ -277,27 +272,20 @@ export class PrimitiveNode extends LGraphNode {
// Restore any saved control values
const controlValues = this.controlValues
if (
// @ts-expect-error fixme ts strict error
this.lastType === this.widgets[0].type &&
// @ts-expect-error fixme ts strict error
this.widgets &&
this.lastType === this.widgets[0]?.type &&
controlValues?.length === this.widgets.length - 1
) {
for (let i = 0; i < controlValues.length; i++) {
// @ts-expect-error fixme ts strict error
this.widgets[i + 1].value = controlValues[i]
}
}
// When our value changes, update other widgets to reflect our changes
// e.g. so LoadImage shows correct image
const callback = widget.callback
const self = this
widget.callback = function () {
// @ts-expect-error fixme ts strict error
const r = callback ? callback.apply(this, arguments) : undefined
self.applyToGraph()
return r
}
widget.callback = useChainCallback(widget.callback, () => {
this.applyToGraph()
})
// Use the biggest dimensions in case the widgets caused the node to grow
this.setSize([
@@ -316,10 +304,7 @@ export class PrimitiveNode extends LGraphNode {
}
requestAnimationFrame(() => {
if (this.onResize) {
// @ts-expect-error fixme ts strict error
this.onResize(this.size)
}
this.onResize?.(this.size)
})
}
}
@@ -328,10 +313,8 @@ export class PrimitiveNode extends LGraphNode {
const values = this.widgets?.map((w) => w.value)
this.#removeWidgets()
this.#onFirstConnection(true)
if (values?.length) {
// @ts-expect-error fixme ts strict error
for (let i = 0; i < this.widgets?.length; i++)
// @ts-expect-error fixme ts strict error
if (values?.length && this.widgets) {
for (let i = 0; i < this.widgets.length; i++)
this.widgets[i].value = values[i]
}
return this.widgets?.[0]
@@ -340,35 +323,32 @@ export class PrimitiveNode extends LGraphNode {
#mergeWidgetConfig() {
// Merge widget configs if the node has multiple outputs
const output = this.outputs[0]
const links = output.links
const links = output.links ?? []
const hasConfig = !!output.widget[CONFIG]
const hasConfig = !!output.widget?.[CONFIG]
if (hasConfig) {
delete output.widget[CONFIG]
delete output.widget?.[CONFIG]
}
// @ts-expect-error fixme ts strict error
if (links?.length < 2 && hasConfig) {
// Copy the widget options from the source
// @ts-expect-error fixme ts strict error
if (links.length) {
this.recreateWidget()
}
return
}
const config1 = output.widget[GET_CONFIG]()
// @ts-expect-error fixme ts strict error
const config1 = output.widget?.[GET_CONFIG]?.()
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
if (!isNumber) return
// @ts-expect-error fixme ts strict error
for (const linkId of links) {
const link = app.graph.links[linkId]
if (!link) continue // Can be null when removing a node
const theirNode = app.graph.getNodeById(link.target_id)
// @ts-expect-error fixme ts strict error
if (!theirNode) continue
const theirInput = theirNode.inputs[link.target_slot]
// Call is valid connection so it can merge the configs when validating
@@ -388,6 +368,7 @@ export class PrimitiveNode extends LGraphNode {
if (!isConvertibleWidget(targetWidget, config2)) return false
const output = this.outputs[originSlot]
// @ts-expect-error fixme ts strict error
if (!(output.widget?.[CONFIG] ?? output.widget?.[GET_CONFIG]())) {
// No widget defined for this primitive yet so allow it
return true
@@ -427,9 +408,7 @@ export class PrimitiveNode extends LGraphNode {
this.controlValues.push(this.widgets[i].value)
}
setTimeout(() => {
// @ts-expect-error fixme ts strict error
delete this.lastType
// @ts-expect-error fixme ts strict error
delete this.controlValues
}, 15)
this.widgets.length = 0
@@ -448,11 +427,11 @@ export class PrimitiveNode extends LGraphNode {
}
export function getWidgetConfig(slot: INodeInputSlot | INodeOutputSlot) {
// @ts-expect-error fixme ts strict error
return slot.widget[CONFIG] ?? slot.widget[GET_CONFIG]?.() ?? ['*', {}]
}
function getConfig(widgetName: string) {
// @ts-expect-error fixme ts strict error
function getConfig(this: LGraphNode, widgetName: string) {
const { nodeData } = this.constructor
return (
nodeData?.input?.required?.[widgetName] ??
@@ -541,15 +520,12 @@ export function convertToInput(
const [oldWidth, oldHeight] = node.size
const inputIsOptional = !!widget.options?.inputIsOptional
const input = node.addInput(widget.name, type, {
// @ts-expect-error [GET_CONFIG] is not a valid property of IWidget
widget: { name: widget.name, [GET_CONFIG]: () => config },
...(inputIsOptional ? { shape: LiteGraph.SlotShape.HollowCircle } : {})
...(inputIsOptional ? { shape: RenderShape.HollowCircle } : {})
})
// @ts-expect-error fixme ts strict error
for (const widget of node.widgets) {
// @ts-expect-error fixme ts strict error
widget.last_y += LiteGraph.NODE_SLOT_HEIGHT
for (const widget of node.widgets ?? []) {
widget.last_y = (widget.last_y ?? 0) + LiteGraph.NODE_SLOT_HEIGHT
}
// Restore original size but grow if needed
@@ -565,10 +541,8 @@ function convertToWidget(node: LGraphNode, widget: IWidget) {
const [oldWidth, oldHeight] = node.size
node.removeInput(node.inputs.findIndex((i) => i.widget?.name === widget.name))
// @ts-expect-error fixme ts strict error
for (const widget of node.widgets) {
// @ts-expect-error fixme ts strict error
widget.last_y -= LiteGraph.NODE_SLOT_HEIGHT
for (const widget of node.widgets ?? []) {
widget.last_y = (widget.last_y ?? 0) - LiteGraph.NODE_SLOT_HEIGHT
}
// Restore original size but grow if needed
@@ -599,12 +573,10 @@ export function setWidgetConfig(
}
if ('link' in slot) {
// @ts-expect-error fixme ts strict error
const link = app.graph.links[slot.link]
const link = app.graph.links[slot.link ?? -1]
if (link) {
const originNode = app.graph.getNodeById(link.origin_id)
// @ts-expect-error fixme ts strict error
if (isPrimitiveNode(originNode)) {
if (originNode && isPrimitiveNode(originNode)) {
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
@@ -632,6 +604,7 @@ export function mergeIfValid(
if (customSpec || forceUpdate) {
if (customSpec) {
// @ts-expect-error fixme ts strict error
output.widget[CONFIG] = customSpec
}
@@ -718,8 +691,9 @@ app.registerExtension({
) {
if (!slot.input || !slot.input.widget) return []
// @ts-expect-error fixme ts strict error
const widget = this.widgets.find((w) => w.name === slot.input.widget.name)
const widget = this.widgets?.find(
(w) => w.name === slot.input?.widget?.name
)
if (!widget) return []
return [
{
@@ -746,8 +720,7 @@ app.registerExtension({
}
if (this.widgets) {
// @ts-expect-error fixme ts strict error
const { canvasX, canvasY } = getPointerCanvasPos()
const { canvasX = 0, canvasY = 0 } = getPointerCanvasPos() ?? {}
const widget = this.getWidgetOnPos(canvasX, canvasY)
// @ts-expect-error custom widget type
if (widget && widget.type !== CONVERTED_TYPE) {
@@ -821,95 +794,66 @@ app.registerExtension({
return r
}
nodeType.prototype.onGraphConfigured = function (this: LGraphNode) {
if (!this.inputs) return
this.widgets ??= []
nodeType.prototype.onGraphConfigured = useChainCallback(
nodeType.prototype.onGraphConfigured,
function (this: LGraphNode) {
if (!this.inputs) return
this.widgets ??= []
for (const input of this.inputs) {
if (input.widget) {
// @ts-expect-error fixme ts strict error
if (!input.widget[GET_CONFIG]) {
// @ts-expect-error fixme ts strict error
input.widget[GET_CONFIG] = () =>
// @ts-expect-error fixme ts strict error
getConfig.call(this, input.widget.name)
}
// Cleanup old widget config
// @ts-expect-error WidgetRef
if (input.widget.config) {
// @ts-expect-error WidgetRef
if (input.widget.config[0] instanceof Array) {
// If we are an old converted combo then replace the input type and the stored link data
input.type = 'COMBO'
// @ts-expect-error fixme ts strict error
const link = app.graph.links[input.link]
if (link) {
link.type = input.type
}
}
// @ts-expect-error WidgetRef
delete input.widget.config
}
// @ts-expect-error fixme ts strict error
const w = this.widgets.find((w) => w.name === input.widget.name)
if (w) {
hideWidget(this, w)
} else {
this.removeInput(this.inputs.findIndex((i) => i === input))
}
}
}
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function (this: LGraphNode) {
const r = origOnNodeCreated ? origOnNodeCreated.apply(this) : undefined
// When node is created, convert any force/default inputs
if (!app.configuringGraph && this.widgets) {
for (const w of this.widgets) {
if (w?.options?.forceInput || w?.options?.defaultInput) {
const config = getConfig.call(this, w.name) ?? [
w.type,
w.options || {}
]
convertToInput(this, w, config)
}
}
}
return r
}
const origOnConfigure = nodeType.prototype.onConfigure
nodeType.prototype.onConfigure = function (this: LGraphNode) {
const r = origOnConfigure
? // @ts-expect-error fixme ts strict error
origOnConfigure.apply(this, arguments)
: undefined
if (!app.configuringGraph && this.inputs) {
// On copy + paste of nodes, ensure that widget configs are set up
for (const input of this.inputs) {
// @ts-expect-error fixme ts strict error
if (input.widget && !input.widget[GET_CONFIG]) {
// @ts-expect-error fixme ts strict error
input.widget[GET_CONFIG] = () =>
// @ts-expect-error fixme ts strict error
getConfig.call(this, input.widget.name)
// @ts-expect-error fixme ts strict error
const w = this.widgets.find((w) => w.name === input.widget.name)
if (input.widget) {
const name = input.widget.name
if (!input.widget[GET_CONFIG]) {
input.widget[GET_CONFIG] = () => getConfig.call(this, name)
}
const w = this.widgets?.find((w) => w.name === name)
if (w) {
hideWidget(this, w)
} else {
this.removeInput(this.inputs.findIndex((i) => i === input))
}
}
}
}
)
return r
}
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
function (this: LGraphNode) {
// When node is created, convert any force/default inputs
if (!app.configuringGraph && this.widgets) {
for (const w of this.widgets) {
if (w?.options?.forceInput || w?.options?.defaultInput) {
const config = getConfig.call(this, w.name) ?? [
w.type,
w.options || {}
]
convertToInput(this, w, config)
}
}
}
}
)
nodeType.prototype.onConfigure = useChainCallback(
nodeType.prototype.onConfigure,
function (this: LGraphNode) {
if (!app.configuringGraph && this.inputs) {
// On copy + paste of nodes, ensure that widget configs are set up
for (const input of this.inputs) {
if (input.widget && !input.widget[GET_CONFIG]) {
const name = input.widget.name
input.widget[GET_CONFIG] = () => getConfig.call(this, name)
const w = this.widgets?.find((w) => w.name === name)
if (w) {
hideWidget(this, w)
}
}
}
}
}
)
function isNodeAtPos(pos: Vector2) {
for (const n of app.graph.nodes) {
@@ -945,12 +889,12 @@ app.registerExtension({
// Create a primitive node
const node = LiteGraph.createNode('PrimitiveNode')
// @ts-expect-error fixme ts strict error
if (!node) return r
app.graph.add(node)
// Calculate a position that wont directly overlap another node
const pos: [number, number] = [
// @ts-expect-error fixme ts strict error
this.pos[0] - node.size[0] - 30,
this.pos[1]
]
@@ -958,11 +902,8 @@ app.registerExtension({
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
}
// @ts-expect-error fixme ts strict error
node.pos = pos
// @ts-expect-error fixme ts strict error
node.connect(0, this, slot)
// @ts-expect-error fixme ts strict error
node.title = input.name
return r
@@ -971,7 +912,6 @@ app.registerExtension({
registerCustomNodes() {
LiteGraph.registerNodeType(
'PrimitiveNode',
// @ts-expect-error fixme ts strict error
Object.assign(PrimitiveNode, {
title: 'Primitive'
})

View File

@@ -101,10 +101,17 @@
"missing": "Missing",
"inProgress": "In progress",
"completed": "Completed",
"interrupted": "Interrupted"
"interrupted": "Interrupted",
"enabling": "Enabling",
"disabling": "Disabling",
"updating": "Updating",
"migrate": "Migrate"
},
"manager": {
"title": "Custom Nodes Manager",
"failed": "Failed ({count})",
"installationQueue": "Installation Queue",
"changingVersion": "Changing version from {from} to {to}",
"dependencies": "Dependencies",
"inWorkflow": "In Workflow",
"infoPanelEmpty": "Click an item to see the info",
@@ -117,6 +124,7 @@
"uninstalling": "Uninstalling",
"update": "Update",
"uninstallSelected": "Uninstall Selected",
"updatingAllPacks": "Updating all packages",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
@@ -705,7 +713,8 @@
"EditTokenWeight": "Edit Token Weight",
"CustomColorPalettes": "Custom Color Palettes",
"UV": "UV",
"ContextMenu": "Context Menu"
"ContextMenu": "Context Menu",
"Reroute": "Reroute"
},
"serverConfigItems": {
"listen": {
@@ -958,7 +967,8 @@
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
"noStackTrace": "No stacktrace available",
"extensionFileHint": "This may be due to the following script"
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed"
},
"desktopUpdate": {
"title": "Updating ComfyUI Desktop",
@@ -1015,6 +1025,8 @@
"nodeDefinitionsUpdated": "Node definitions updated",
"errorSaveSetting": "Error saving setting {id}: {err}",
"errorCopyImage": "Error copying image: {error}",
"noTemplatesToExport": "No templates to export"
"noTemplatesToExport": "No templates to export",
"failedToFetchLogs": "Failed to fetch server logs",
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute."
}
}

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Tooltip Delay"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Reroute spline offset",
"tooltip": "The bezier control point offset from the reroute centre point"
},
"pysssss_SnapToGrid": {
"name": "Always snap to grid"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "Ocurrió un error",
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible"
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló"
},
"g": {
"about": "Acerca de",
@@ -151,10 +152,12 @@
"description": "Descripción",
"devices": "Dispositivos",
"disableAll": "Deshabilitar todo",
"disabling": "Deshabilitando",
"download": "Descargar",
"empty": "Vacío",
"enableAll": "Habilitar todo",
"enabled": "Habilitado",
"enabling": "Habilitando",
"error": "Error",
"experimental": "BETA",
"export": "Exportar",
@@ -178,6 +181,7 @@
"loadWorkflow": "Cargar flujo de trabajo",
"loading": "Cargando",
"logs": "Registros",
"migrate": "Migrar",
"missing": "Faltante",
"name": "Nombre",
"newFolder": "Nueva carpeta",
@@ -221,6 +225,7 @@
"terminal": "Terminal",
"update": "Actualizar",
"updated": "Actualizado",
"updating": "Actualizando",
"upload": "Subir",
"videoFailedToLoad": "Falló la carga del video",
"workflow": "Flujo de trabajo"
@@ -399,11 +404,13 @@
"title": "Mantenimiento"
},
"manager": {
"changingVersion": "Cambiando versión de {from} a {to}",
"createdBy": "Creado Por",
"dependencies": "Dependencias",
"discoverCommunityContent": "Descubre paquetes de nodos, extensiones y más creados por la comunidad...",
"downloads": "Descargas",
"errorConnecting": "Error al conectar con el Registro de Nodos Comfy.",
"failed": "Falló ({count})",
"filter": {
"disabled": "Deshabilitado",
"enabled": "Habilitado",
@@ -412,6 +419,7 @@
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
"latestVersion": "Última",
"license": "Licencia",
@@ -445,6 +453,7 @@
"uninstallSelected": "Desinstalar Seleccionado",
"uninstalling": "Desinstalando",
"update": "Actualizar",
"updatingAllPacks": "Actualizando todos los paquetes",
"version": "Versión"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "Puntero",
"Queue": "Cola",
"QueueButton": "Botón de Cola",
"Reroute": "Reenrutar",
"RerouteBeta": "Reroute Beta",
"Server": "Servidor",
"Server-Config": "Configuración del Servidor",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "Error al aplicar textura",
"failedToDownloadFile": "Error al descargar el archivo",
"failedToExportModel": "Error al exportar modelo como {format}",
"failedToFetchLogs": "Error al obtener los registros del servidor",
"fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}",
"fileUploadFailed": "Error al subir el archivo",
"interrupted": "La ejecución ha sido interrumpida",
"migrateToLitegraphReroute": "Los nodos de reroute se eliminarán en futuras versiones. Haz clic para migrar a reroute nativo de litegraph.",
"no3dScene": "No hay escena 3D para aplicar textura",
"no3dSceneToExport": "No hay escena 3D para exportar",
"noTemplatesToExport": "No hay plantillas para exportar",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Retraso de la información sobre herramientas"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Desvío de la compensación de la spline",
"tooltip": "El punto de control bezier desplazado desde el punto central de reenrutamiento"
},
"pysssss_SnapToGrid": {
"name": "Siempre ajustar a la cuadrícula"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "Une erreur est survenue",
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
"noStackTrace": "Aucune trace de pile disponible"
"noStackTrace": "Aucune trace de pile disponible",
"promptExecutionError": "L'exécution de l'invite a échoué"
},
"g": {
"about": "À propos",
@@ -151,10 +152,12 @@
"description": "Description",
"devices": "Appareils",
"disableAll": "Désactiver tout",
"disabling": "Désactivation",
"download": "Télécharger",
"empty": "Vide",
"enableAll": "Activer tout",
"enabled": "Activé",
"enabling": "Activation",
"error": "Erreur",
"experimental": "BETA",
"export": "Exportation",
@@ -178,6 +181,7 @@
"loadWorkflow": "Charger le flux de travail",
"loading": "Chargement",
"logs": "Journaux",
"migrate": "Migrer",
"missing": "Manquant",
"name": "Nom",
"newFolder": "Nouveau dossier",
@@ -221,6 +225,7 @@
"terminal": "Terminal",
"update": "Mettre à jour",
"updated": "Mis à jour",
"updating": "Mise à jour",
"upload": "Téléverser",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
@@ -399,11 +404,13 @@
"title": "Maintenance"
},
"manager": {
"changingVersion": "Changement de version de {from} à {to}",
"createdBy": "Créé par",
"dependencies": "Dépendances",
"discoverCommunityContent": "Découvrez les packs de nœuds, extensions et plus encore créés par la communauté...",
"downloads": "Téléchargements",
"errorConnecting": "Erreur de connexion au registre de nœuds Comfy.",
"failed": "Échoué ({count})",
"filter": {
"disabled": "Désactivé",
"enabled": "Activé",
@@ -412,6 +419,7 @@
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
"latestVersion": "Dernière",
"license": "Licence",
@@ -445,6 +453,7 @@
"uninstallSelected": "Désinstaller sélectionné",
"uninstalling": "Désinstallation",
"update": "Mettre à jour",
"updatingAllPacks": "Mise à jour de tous les paquets",
"version": "Version"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "Pointeur",
"Queue": "File d'Attente",
"QueueButton": "Bouton de File d'Attente",
"Reroute": "Réacheminement",
"RerouteBeta": "Reroute Beta",
"Server": "Serveur",
"Server-Config": "Config-Serveur",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "Échec de l'application de la texture",
"failedToDownloadFile": "Échec du téléchargement du fichier",
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
"failedToFetchLogs": "Échec de la récupération des journaux du serveur",
"fileLoadError": "Impossible de trouver le flux de travail dans {fileName}",
"fileUploadFailed": "Échec du téléchargement du fichier",
"interrupted": "L'exécution a été interrompue",
"migrateToLitegraphReroute": "Les nœuds de reroute seront supprimés dans les futures versions. Cliquez pour migrer vers le reroute natif de litegraph.",
"no3dScene": "Aucune scène 3D pour appliquer la texture",
"no3dSceneToExport": "Aucune scène 3D à exporter",
"noTemplatesToExport": "Aucun modèle à exporter",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Délai d'infobulle"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Réacheminement décalage de spline",
"tooltip": "Le point de contrôle de Bézier est décalé par rapport au point central de réacheminement"
},
"pysssss_SnapToGrid": {
"name": "Toujours aligner sur la grille"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "エラーが発生しました",
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
"noStackTrace": "スタックトレースは利用できません"
"noStackTrace": "スタックトレースは利用できません",
"promptExecutionError": "プロンプトの実行に失敗しました"
},
"g": {
"about": "情報",
@@ -151,10 +152,12 @@
"description": "説明",
"devices": "デバイス",
"disableAll": "すべて無効にする",
"disabling": "無効化",
"download": "ダウンロード",
"empty": "空",
"enableAll": "すべて有効にする",
"enabled": "有効",
"enabling": "有効化",
"error": "エラー",
"experimental": "ベータ",
"export": "エクスポート",
@@ -178,6 +181,7 @@
"loadWorkflow": "ワークフローを読み込む",
"loading": "読み込み中",
"logs": "ログ",
"migrate": "移行する",
"missing": "不足している",
"name": "名前",
"newFolder": "新しいフォルダー",
@@ -221,6 +225,7 @@
"terminal": "ターミナル",
"update": "更新",
"updated": "更新済み",
"updating": "更新中",
"upload": "アップロード",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
@@ -399,11 +404,13 @@
"title": "メンテナンス"
},
"manager": {
"changingVersion": "バージョンを {from} から {to} に変更",
"createdBy": "作成者",
"dependencies": "依存関係",
"discoverCommunityContent": "コミュニティが作成したノードパック、拡張機能などを探す...",
"downloads": "ダウンロード",
"errorConnecting": "Comfy Node Registryへの接続エラー。",
"failed": "失敗しました ({count})",
"filter": {
"disabled": "無効",
"enabled": "有効",
@@ -412,6 +419,7 @@
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
"latestVersion": "最新",
"license": "ライセンス",
@@ -445,6 +453,7 @@
"uninstallSelected": "選択したものをアンインストール",
"uninstalling": "アンインストール中",
"update": "更新",
"updatingAllPacks": "すべてのパッケージを更新中",
"version": "バージョン"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "ポインタ",
"Queue": "キュー",
"QueueButton": "キューボタン",
"Reroute": "リルート",
"RerouteBeta": "ルート変更ベータ",
"Server": "サーバー",
"Server-Config": "サーバー設定",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "テクスチャの適用に失敗しました",
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
"failedToFetchLogs": "サーバーログの取得に失敗しました",
"fileLoadError": "{fileName}でワークフローが見つかりません",
"fileUploadFailed": "ファイルのアップロードに失敗しました",
"interrupted": "実行が中断されました",
"migrateToLitegraphReroute": "将来のバージョンではRerouteードが削除されます。litegraph-native rerouteに移行するにはクリックしてください。",
"no3dScene": "テクスチャを適用する3Dシーンがありません",
"no3dSceneToExport": "エクスポートする3Dシーンがありません",
"noTemplatesToExport": "エクスポートするテンプレートがありません",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "ツールチップ遅延"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "リルートスプラインオフセット",
"tooltip": "リルート中心点からのベジエ制御点のオフセット"
},
"pysssss_SnapToGrid": {
"name": "常にグリッドにスナップ"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "오류가 발생했습니다",
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로우 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적이 사용할 수 없습니다"
"noStackTrace": "스택 추적이 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패"
},
"g": {
"about": "정보",
@@ -151,10 +152,12 @@
"description": "설명",
"devices": "장치",
"disableAll": "모두 비활성화",
"disabling": "비활성화 중",
"download": "다운로드",
"empty": "비어 있음",
"enableAll": "모두 활성화",
"enabled": "활성화됨",
"enabling": "활성화 중",
"error": "오류",
"experimental": "베타",
"export": "내보내기",
@@ -178,6 +181,7 @@
"loadWorkflow": "워크플로 로드",
"loading": "로딩 중",
"logs": "로그",
"migrate": "마이그레이트",
"missing": "누락됨",
"name": "이름",
"newFolder": "새 폴더",
@@ -221,6 +225,7 @@
"terminal": "터미널",
"update": "업데이트",
"updated": "업데이트 됨",
"updating": "업데이트 중",
"upload": "업로드",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로"
@@ -399,11 +404,13 @@
"title": "유지 보수"
},
"manager": {
"changingVersion": "{from}에서 {to}로 버전 변경 중",
"createdBy": "작성자",
"dependencies": "의존성",
"discoverCommunityContent": "커뮤니티에서 만든 노드 팩 및 확장 프로그램을 찾아보세요...",
"downloads": "다운로드",
"errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.",
"failed": "실패 ({count})",
"filter": {
"disabled": "비활성화",
"enabled": "활성화",
@@ -412,6 +419,7 @@
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"license": "라이선스",
@@ -445,6 +453,7 @@
"uninstallSelected": "선택 항목 제거",
"uninstalling": "제거 중",
"update": "업데이트",
"updatingAllPacks": "모든 패키지 업데이트 중",
"version": "버전"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "포인터",
"Queue": "실행 큐",
"QueueButton": "실행 큐 버튼",
"Reroute": "재경로",
"RerouteBeta": "경로재설정 베타",
"Server": "서버",
"Server-Config": "서버 구성",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
"failedToFetchLogs": "서버 로그를 가져오는 데 실패했습니다",
"fileLoadError": "{fileName}에서 워크플로우를 찾을 수 없습니다",
"fileUploadFailed": "파일 업로드에 실패했습니다",
"interrupted": "실행이 중단되었습니다",
"migrateToLitegraphReroute": "미래 버전에서는 Reroute 노드가 제거됩니다. litegraph-native reroute로 마이그레이트하려면 클릭하세요.",
"no3dScene": "텍스처를 적용할 3D 장면이 없습니다",
"no3dSceneToExport": "내보낼 3D 장면이 없습니다",
"noTemplatesToExport": "내보낼 템플릿이 없습니다",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "툴팁 지연"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Reroute 스플라인 오프셋",
"tooltip": "재경로 중심점에서 베지어 제어점까지의 오프셋"
},
"pysssss_SnapToGrid": {
"name": "항상 그리드에 스냅"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "Произошла ошибка",
"extensionFileHint": "Это может быть связано со следующим скриптом",
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
"noStackTrace": "Стек вызовов недоступен"
"noStackTrace": "Стек вызовов недоступен",
"promptExecutionError": "Ошибка выполнения запроса"
},
"g": {
"about": "О программе",
@@ -151,10 +152,12 @@
"description": "Описание",
"devices": "Устройства",
"disableAll": "Отключить все",
"disabling": "Отключение",
"download": "Скачать",
"empty": "Пусто",
"enableAll": "Включить все",
"enabled": "Включено",
"enabling": "Включение",
"error": "Ошибка",
"experimental": "БЕТА",
"export": "Экспорт",
@@ -178,6 +181,7 @@
"loadWorkflow": "Загрузить рабочий процесс",
"loading": "Загрузка",
"logs": "Логи",
"migrate": "Мигрировать",
"missing": "Отсутствует",
"name": "Имя",
"newFolder": "Новая папка",
@@ -221,6 +225,7 @@
"terminal": "Терминал",
"update": "Обновить",
"updated": "Обновлено",
"updating": "Обновление",
"upload": "Загрузить",
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
@@ -399,11 +404,13 @@
"title": "Обслуживание"
},
"manager": {
"changingVersion": "Изменение версии с {from} на {to}",
"createdBy": "Создано",
"dependencies": "Зависимости",
"discoverCommunityContent": "Откройте для себя пакеты узлов, расширения и многое другое, созданные сообществом...",
"downloads": "Загрузки",
"errorConnecting": "Ошибка подключения к реестру Comfy Node.",
"failed": "Не удалось ({count})",
"filter": {
"disabled": "Отключено",
"enabled": "Включено",
@@ -412,6 +419,7 @@
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
"latestVersion": "Последняя",
"license": "Лицензия",
@@ -445,6 +453,7 @@
"uninstallSelected": "Удалить выбранное",
"uninstalling": "Удаление",
"update": "Обновить",
"updatingAllPacks": "Обновление всех пакетов",
"version": "Версия"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "Указатель",
"Queue": "Очередь",
"QueueButton": "Кнопка очереди",
"Reroute": "Перенаправление",
"RerouteBeta": "Бета-версия перенаправления",
"Server": "Сервер",
"Server-Config": "Настройки сервера",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "Не удалось применить текстуру",
"failedToDownloadFile": "Не удалось скачать файл",
"failedToExportModel": "Не удалось экспортировать модель как {format}",
"failedToFetchLogs": "Не удалось получить серверные логи",
"fileLoadError": "Не удалось найти рабочий процесс в {fileName}",
"fileUploadFailed": "Не удалось загрузить файл",
"interrupted": "Выполнение было прервано",
"migrateToLitegraphReroute": "Узлы перенаправления будут удалены в будущих версиях. Нажмите, чтобы перейти на litegraph-native reroute.",
"no3dScene": "Нет 3D сцены для применения текстуры",
"no3dSceneToExport": "Нет 3D сцены для экспорта",
"noTemplatesToExport": "Нет шаблонов для экспорта",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Задержка всплывающей подсказки"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Перераспределение смещения сплайна",
"tooltip": "Смещение контрольной точки Безье от центральной точки перераспределения"
},
"pysssss_SnapToGrid": {
"name": "Всегда привязываться к сетке"
}

View File

@@ -119,7 +119,8 @@
"defaultTitle": "发生错误",
"extensionFileHint": "这可能是由于以下脚本",
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
"noStackTrace": "无可用堆栈跟踪"
"noStackTrace": "无可用堆栈跟踪",
"promptExecutionError": "提示执行失败"
},
"g": {
"about": "关于",
@@ -151,10 +152,12 @@
"description": "描述",
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"download": "下载",
"empty": "空",
"enableAll": "启用全部",
"enabled": "已启用",
"enabling": "启用中",
"error": "错误",
"experimental": "测试版",
"export": "导出",
@@ -178,6 +181,7 @@
"loadWorkflow": "加载工作流",
"loading": "加载中",
"logs": "日志",
"migrate": "迁移",
"missing": "缺失",
"name": "名称",
"newFolder": "新文件夹",
@@ -221,6 +225,7 @@
"terminal": "终端",
"update": "更新",
"updated": "已更新",
"updating": "更新中",
"upload": "上传",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
@@ -399,11 +404,13 @@
"title": "维护"
},
"manager": {
"changingVersion": "将版本从 {from} 更改为 {to}",
"createdBy": "创建者",
"dependencies": "依赖关系",
"discoverCommunityContent": "发现社区制作的节点包,扩展等等...",
"downloads": "下载",
"errorConnecting": "连接到Comfy节点注册表时出错。",
"failed": "失败 ({count})",
"filter": {
"disabled": "已禁用",
"enabled": "已启用",
@@ -412,6 +419,7 @@
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
"latestVersion": "最新",
"license": "许可证",
@@ -445,6 +453,7 @@
"uninstallSelected": "卸载所选",
"uninstalling": "正在卸载",
"update": "更新",
"updatingAllPacks": "更新所有包",
"version": "版本"
},
"maskEditor": {
@@ -826,6 +835,7 @@
"Pointer": "指针",
"Queue": "队列",
"QueueButton": "执行按钮",
"Reroute": "重新路由",
"RerouteBeta": "转接点 Beta",
"Server": "服务器",
"Server-Config": "服务器配置",
@@ -988,9 +998,11 @@
"failedToApplyTexture": "应用纹理失败",
"failedToDownloadFile": "文件下载失败",
"failedToExportModel": "无法将模型导出为 {format}",
"failedToFetchLogs": "无法获取服务器日志",
"fileLoadError": "无法在 {fileName} 中找到工作流",
"fileUploadFailed": "文件上传失败",
"interrupted": "执行已被中断",
"migrateToLitegraphReroute": "将来的版本中将删除重定向节点。点击以迁移到litegraph-native重定向。",
"no3dScene": "没有3D场景可以应用纹理",
"no3dSceneToExport": "没有3D场景可以导出",
"noTemplatesToExport": "没有模板可以导出",

View File

@@ -333,6 +333,10 @@
"LiteGraph_Node_TooltipDelay": {
"name": "工具提示延迟"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "重新路由样条偏移",
"tooltip": "贝塞尔控制点从重新路由中心点的偏移"
},
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}

View File

@@ -255,14 +255,31 @@ export function validateTaskItem(taskItem: unknown) {
const zEmbeddingsResponse = z.array(z.string())
const zExtensionsResponse = z.array(z.string())
const zError = z.object({
type: z.string(),
message: z.string(),
details: z.string(),
extra_info: z
.object({
input_name: z.string().optional()
})
.passthrough()
.optional()
})
const zNodeError = z.object({
errors: z.array(zError),
class_type: z.string(),
dependent_outputs: z.array(z.any())
})
const zPromptResponse = z.object({
node_errors: z.array(z.string()).optional(),
node_errors: z.record(zNodeId, zNodeError).optional(),
prompt_id: z.string().optional(),
exec_info: z
.object({
queue_remaining: z.number().optional()
})
.optional()
.optional(),
error: z.union([z.string(), zError])
})
const zDeviceStats = z.object({
@@ -414,6 +431,7 @@ const zSettings = z.record(z.any()).and(
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
export type PromptResponse = z.infer<typeof zPromptResponse>
export type NodeError = z.infer<typeof zNodeError>
export type Settings = z.infer<typeof zSettings>
export type DeviceStats = z.infer<typeof zDeviceStats>
export type SystemStats = z.infer<typeof zSystemStats>

View File

@@ -82,7 +82,12 @@ const zReroute = z
id: z.number(),
parentId: z.number().optional(),
pos: zVector2,
linkIds: z.array(z.number()).nullish()
linkIds: z.array(z.number()).nullish(),
floating: z
.object({
slotType: z.enum(['input', 'output'])
})
.optional()
})
.passthrough()
@@ -277,6 +282,7 @@ export type ModelFile = z.infer<typeof zModelFile>
export type NodeInput = z.infer<typeof zNodeInput>
export type NodeOutput = z.infer<typeof zNodeOutput>
export type ComfyLink = z.infer<typeof zComfyLink>
export type ComfyLinkObject = z.infer<typeof zComfyLinkObject>
export type ComfyNode = z.infer<typeof zComfyNode>
export type Reroute = z.infer<typeof zReroute>
export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>

View File

@@ -79,6 +79,8 @@ interface ApiMessage<T extends keyof ApiCalls> {
data: ApiCalls[T]
}
export class UnauthorizedError extends Error {}
/** Ensures workers get a fair shake. */
type Unionize<T> = T[keyof T]
@@ -145,6 +147,36 @@ export interface ComfyApi extends EventTarget {
): void
}
export class PromptExecutionError extends Error {
response: PromptResponse
constructor(response: PromptResponse) {
super('Prompt execution failed')
this.response = response
}
override toString() {
let message = ''
if (typeof this.response.error === 'string') {
message += this.response.error
} else if (this.response.error) {
message +=
this.response.error.message + ': ' + this.response.error.details
}
for (const [_, nodeError] of Object.entries(
this.response.node_errors ?? []
)) {
message += '\n' + nodeError.class_type + ':'
for (const errorReason of nodeError.errors) {
message += '\n - ' + errorReason.message + ': ' + errorReason.details
}
}
return message
}
}
export class ComfyApi extends EventTarget {
#registered = new Set()
api_host: string
@@ -464,9 +496,10 @@ export class ComfyApi extends EventTarget {
}
/**
*
* Queues a prompt to be executed
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
* @param {object} prompt The prompt data to queue
* @throws {PromptExecutionError} If the prompt fails to execute
*/
async queuePrompt(
number: number,
@@ -496,9 +529,7 @@ export class ComfyApi extends EventTarget {
})
if (res.status !== 200) {
throw {
response: await res.json()
}
throw new PromptExecutionError(await res.json())
}
return await res.json()
@@ -704,7 +735,12 @@ export class ComfyApi extends EventTarget {
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings(): Promise<Settings> {
return (await this.fetchApi('/settings')).json()
const resp = await this.fetchApi('/settings')
if (resp.status == 401) {
throw new UnauthorizedError(resp.statusText)
}
return await resp.json()
}
/**

View File

@@ -3,16 +3,20 @@ import {
LGraphCanvas,
LGraphEventMode,
LGraphNode,
LiteGraph,
strokeShape
LiteGraph
} from '@comfyorg/litegraph'
import type { IWidget, Rect, Vector2 } from '@comfyorg/litegraph'
import type { IWidget, Vector2 } from '@comfyorg/litegraph'
import _ from 'lodash'
import type { ToastMessageOptions } from 'primevue/toast'
import { reactive } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { st, t } from '@/i18n'
import type { ResultItem } from '@/schemas/apiSchema'
import type {
ExecutionErrorWsMessage,
NodeError,
ResultItem
} from '@/schemas/apiSchema'
import {
ComfyApiWorkflow,
type ComfyWorkflowJSON,
@@ -44,10 +48,13 @@ import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { executeWidgetsCallback, isImageNode } from '@/utils/litegraphUtil'
import { migrateLegacyRerouteNodes } from '@/utils/migration/migrateReroute'
import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, api } from './api'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import {
getFlacMetadata,
@@ -121,9 +128,6 @@ export class ComfyApp {
dragOverNode: LGraphNode | null = null
// @ts-expect-error fixme ts strict error
canvasEl: HTMLCanvasElement
lastNodeErrors: any[] | null = null
/** @type {ExecutionErrorWsMessage} */
lastExecutionError: { node_id?: NodeId } | null = null
configuringGraph: boolean = false
// @ts-expect-error fixme ts strict error
ctx: CanvasRenderingContext2D
@@ -137,10 +141,31 @@ export class ComfyApp {
// Set by Comfy.Clipspace extension
openClipspace: () => void = () => {}
#positionConversion?: {
clientPosToCanvasPos: (pos: Vector2) => Vector2
canvasPosToClientPos: (pos: Vector2) => Vector2
}
/**
* The node errors from the previous execution.
* @deprecated Use useExecutionStore().lastNodeErrors instead
*/
get lastNodeErrors(): Record<NodeId, NodeError> | null {
return useExecutionStore().lastNodeErrors
}
/**
* The error from the previous execution.
* @deprecated Use useExecutionStore().lastExecutionError instead
*/
get lastExecutionError(): ExecutionErrorWsMessage | null {
return useExecutionStore().lastExecutionError
}
/**
* @deprecated Use useExecutionStore().executingNodeId instead
*/
get runningNodeId(): string | null {
get runningNodeId(): NodeId | null {
return useExecutionStore().executingNodeId
}
@@ -244,7 +269,7 @@ export class ComfyApp {
}
getPreviewFormatParam() {
let preview_format = this.ui.settings.getSettingValue('Comfy.PreviewFormat')
let preview_format = useSettingStore().get('Comfy.PreviewFormat')
if (preview_format) return `&preview=${preview_format}`
else return ''
}
@@ -554,99 +579,7 @@ export class ComfyApp {
}
}
/**
* Draws node highlights (executing, drag drop) and progress bar
*/
#addDrawNodeHandler() {
const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape
const self = this
LGraphCanvas.prototype.drawNodeShape = function (
node,
ctx,
size,
_fgcolor,
bgcolor
) {
// @ts-expect-error fixme ts strict error
const res = origDrawNodeShape.apply(this, arguments)
// @ts-expect-error fixme ts strict error
const nodeErrors = self.lastNodeErrors?.[node.id]
let color = null
let lineWidth = 1
// @ts-expect-error fixme ts strict error
if (node.id === +self.runningNodeId) {
color = '#0f0'
} else if (self.dragOverNode && node.id === self.dragOverNode.id) {
color = 'dodgerblue'
} else if (nodeErrors?.errors) {
color = 'red'
lineWidth = 2
} else if (
self.lastExecutionError &&
// @ts-expect-error fixme ts strict error
+self.lastExecutionError.node_id === node.id
) {
color = '#f0f'
lineWidth = 2
}
if (color) {
const area: Rect = [
0,
-LiteGraph.NODE_TITLE_HEIGHT,
size[0],
size[1] + LiteGraph.NODE_TITLE_HEIGHT
]
strokeShape(ctx, area, {
shape: node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE,
thickness: lineWidth,
colour: color,
title_height: LiteGraph.NODE_TITLE_HEIGHT,
collapsed: node.collapsed
})
}
// @ts-expect-error fixme ts strict error
if (self.progress && node.id === +self.runningNodeId) {
ctx.fillStyle = 'green'
ctx.fillRect(
0,
0,
size[0] * (self.progress.value / self.progress.max),
6
)
ctx.fillStyle = bgcolor
}
// Highlight inputs that failed validation
if (nodeErrors) {
ctx.lineWidth = 2
ctx.strokeStyle = 'red'
for (const error of nodeErrors.errors) {
if (error.extra_info && error.extra_info.input_name) {
const inputIndex = node.findInputSlot(error.extra_info.input_name)
if (inputIndex !== -1) {
let pos = node.getConnectionPos(true, inputIndex)
ctx.beginPath()
ctx.arc(
pos[0] - node.pos[0],
pos[1] - node.pos[1],
12,
0,
2 * Math.PI,
false
)
ctx.stroke()
}
}
}
}
return res
}
const origDrawNode = LGraphCanvas.prototype.drawNode
LGraphCanvas.prototype.drawNode = function (node) {
const editor_alpha = this.editor_alpha
@@ -733,15 +666,13 @@ export class ComfyApp {
})
api.addEventListener('execution_start', () => {
this.lastExecutionError = null
this.graph.nodes.forEach((node) => {
if (node.onExecutionStart) node.onExecutionStart()
})
})
api.addEventListener('execution_error', ({ detail }) => {
this.lastExecutionError = detail
useDialogService().showExecutionErrorDialog({ error: detail })
useDialogService().showExecutionErrorDialog(detail)
this.canvas.draw(true, true)
})
@@ -852,6 +783,11 @@ export class ComfyApp {
this.#addDropHandler()
await useExtensionService().invokeExtensionsAsync('setup')
this.#positionConversion = useCanvasPositionConversion(
this.canvasContainer,
this.canvas
)
}
resizeCanvas() {
@@ -1023,7 +959,11 @@ export class ComfyApp {
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {}
{
showMissingNodesDialog = true,
showMissingModelsDialog = true,
checkForRerouteMigration = true
} = {}
) {
if (clean !== false) {
this.clean()
@@ -1049,12 +989,21 @@ export class ComfyApp {
// Ideally we should not block users from loading the workflow.
graphData = validatedGraphData ?? graphData
}
// Migrate legacy reroute nodes to the new format
if (graphData.version === 0.4) {
graphData = migrateLegacyRerouteNodes(graphData)
// Only show the reroute migration warning if the workflow does not have native
// reroutes. Merging reroute network has great complexity, and it is not supported
// for now.
// See: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3317
if (
checkForRerouteMigration &&
graphData.version === 0.4 &&
findLegacyRerouteNodes(graphData).length &&
noNativeReroutes(graphData)
) {
useToastStore().add({
group: 'reroute-migration',
severity: 'warn'
})
}
useWorkflowService().beforeLoadNewGraph()
const missingNodeTypes: MissingNodeType[] = []
@@ -1129,7 +1078,7 @@ export class ComfyApp {
} catch (error) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.loadWorkflowTitle'),
errorType: 'loadWorkflowError'
reportType: 'loadWorkflowError'
})
return
}
@@ -1223,32 +1172,6 @@ export class ComfyApp {
})
}
// @ts-expect-error fixme ts strict error
#formatPromptError(error) {
if (error == null) {
return '(unknown error)'
} else if (typeof error === 'string') {
return error
} else if (error.stack && error.message) {
return error.toString()
} else if (error.response) {
let message = error.response.error.message
if (error.response.error.details)
message += ': ' + error.response.error.details
for (const [_, nodeError] of Object.entries(error.response.node_errors)) {
// @ts-expect-error
message += '\n' + nodeError.class_type + ':'
// @ts-expect-error
for (const errorReason of nodeError.errors) {
message +=
'\n - ' + errorReason.message + ': ' + errorReason.details
}
}
return message
}
return '(unknown error)'
}
async queuePrompt(number: number, batchCount: number = 1): Promise<boolean> {
this.#queueItems.push({ number, batchCount })
@@ -1258,7 +1181,8 @@ export class ComfyApp {
}
this.#processingQueue = true
this.lastNodeErrors = null
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
try {
while (this.#queueItems.length) {
@@ -1272,13 +1196,13 @@ export class ComfyApp {
const p = await this.graphToPrompt()
try {
const res = await api.queuePrompt(number, p)
this.lastNodeErrors = res.node_errors ?? null
if (this.lastNodeErrors?.length) {
executionStore.lastNodeErrors = res.node_errors ?? null
if (executionStore.lastNodeErrors?.length) {
this.canvas.draw(true, true)
} else {
try {
if (res.prompt_id) {
useExecutionStore().storePrompt({
executionStore.storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output),
workflow: useWorkspaceStore().workflow
@@ -1287,13 +1211,14 @@ export class ComfyApp {
}
} catch (error) {}
}
} catch (error) {
const formattedError = this.#formatPromptError(error)
this.ui.dialog.show(formattedError)
// @ts-expect-error fixme ts strict error
if (error.response) {
// @ts-expect-error fixme ts strict error
this.lastNodeErrors = error.response.node_errors
} catch (error: unknown) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.promptExecutionError'),
reportType: 'promptExecutionError'
})
if (error instanceof PromptExecutionError) {
executionStore.lastNodeErrors = error.response.node_errors ?? null
this.canvas.draw(true, true)
}
break
@@ -1315,7 +1240,7 @@ export class ComfyApp {
this.#processingQueue = false
}
api.dispatchCustomEvent('promptQueued', { number, batchCount })
return !this.lastNodeErrors
return !executionStore.lastNodeErrors
}
showErrorOnFileLoad(file: File) {
@@ -1643,26 +1568,23 @@ export class ComfyApp {
this.revokePreviews(id)
}
this.nodePreviewImages = {}
this.lastNodeErrors = null
this.lastExecutionError = null
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
}
clientPosToCanvasPos(pos: Vector2): Vector2 {
const rect = this.canvasContainer.getBoundingClientRect()
const containerOffsets = [rect.left, rect.top]
return _.zip(pos, this.canvas.ds.offset, containerOffsets).map(
// @ts-expect-error fixme ts strict error
([p, o1, o2]) => (p - o2) / this.canvas.ds.scale - o1
) as Vector2
if (!this.#positionConversion) {
throw new Error('clientPosToCanvasPos called before setup')
}
return this.#positionConversion.clientPosToCanvasPos(pos)
}
canvasPosToClientPos(pos: Vector2): Vector2 {
const rect = this.canvasContainer.getBoundingClientRect()
const containerOffsets = [rect.left, rect.top]
return _.zip(pos, this.canvas.ds.offset, containerOffsets).map(
// @ts-expect-error fixme ts strict error
([p, o1, o2]) => (p + o1) * this.canvas.ds.scale + o2
) as Vector2
if (!this.#positionConversion) {
throw new Error('canvasPosToClientPos called before setup')
}
return this.#positionConversion.canvasPosToClientPos(pos)
}
}

View File

@@ -8,6 +8,7 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { filterSerializedWidgetValues } from '@/utils/litegraphUtil'
import { api } from './api'
import type { ComfyApp } from './app'
@@ -135,7 +136,8 @@ export class ChangeTracker {
try {
await this.app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
showMissingNodesDialog: false
showMissingNodesDialog: false,
checkForRerouteMigration: false
})
this.activeState = prevState
this.updateModified()
@@ -384,7 +386,10 @@ export class ChangeTracker {
if (
!_.isEqualWith(a.nodes, b.nodes, (arrA, arrB) => {
if (Array.isArray(arrA) && Array.isArray(arrB)) {
return _.isEqual(new Set(arrA), new Set(arrB))
// Filter non-serializable widget values before comparison
const filteredArrA = filterSerializedWidgetValues(arrA, app.graph)
const filteredArrB = filterSerializedWidgetValues(arrB, app.graph)
return _.isEqual(new Set(filteredArrA), new Set(filteredArrB))
}
})
) {

View File

@@ -56,6 +56,9 @@ export interface ComponentWidget<V extends object | string>
export interface DOMWidgetOptions<V extends object | string>
extends IWidgetOptions {
/**
* Whether to render a placeholder rectangle when zoomed out.
*/
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: BaseDOMWidget<V>) => void
@@ -145,7 +148,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
widget_height: number,
lowQuality?: boolean
): void {
if (this.options.hideOnZoom && lowQuality) {
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
// Draw a placeholder rectangle
const originalFillStyle = ctx.fillStyle
ctx.beginPath()

View File

@@ -0,0 +1,65 @@
import { IWidget, LGraphNode } from '@comfyorg/litegraph'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
const StringWidget = useStringWidget()
const FloatWidget = useFloatWidget()
const BooleanWidget = useBooleanWidget()
function addWidgetFromValue(node: LGraphNode, value: unknown) {
let widget: IWidget
if (typeof value === 'string') {
widget = StringWidget(node, {
type: 'STRING',
name: 'UNKNOWN',
multiline: value.length > 20
})
} else if (typeof value === 'number') {
widget = FloatWidget(node, {
type: 'FLOAT',
name: 'UNKNOWN'
})
} else if (typeof value === 'boolean') {
widget = BooleanWidget(node, {
type: 'BOOLEAN',
name: 'UNKNOWN'
})
} else {
widget = StringWidget(node, {
type: 'STRING',
name: 'UNKNOWN',
multiline: true
})
widget.value = JSON.stringify(value)
return
}
widget.value = value
}
/**
* Try add widgets to node with missing definition.
*/
LGraphNode.prototype.onConfigure = useChainCallback(
LGraphNode.prototype.onConfigure,
function (this: LGraphNode, info) {
if (!this.has_errors || !info.widgets_values) return
/**
* Note: Some custom nodes overrides the `widgets_values` property to an
* object that has `length` property and index access. It is not safe to call
* any array methods on it.
* See example in https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/8629188458dc6cb832f871ece3bd273507e8a766/web/js/VHS.core.js#L59-L84
*/
for (let i = 0; i < info.widgets_values.length; i++) {
const widgetValue = info.widgets_values[i]
addWidgetFromValue(this, widgetValue)
}
this.serialize_widgets = true
}
)

View File

@@ -20,6 +20,7 @@ import { useSettingStore } from '@/stores/settingStore'
import type { ComfyApp } from './app'
import './domWidget'
import './errorNodeWidgets'
export type ComfyWidgetConstructorV2 = (
node: LGraphNode,

View File

@@ -181,6 +181,7 @@ export const useColorPaletteService = () => {
}
return {
getActiveColorPalette: () => colorPaletteStore.completedActivePalette,
addCustomColorPalette: wrapWithErrorHandling(addCustomColorPalette),
deleteCustomColorPalette: wrapWithErrorHandling(deleteCustomColorPalette),
loadColorPalette: wrapWithErrorHandlingAsync(loadColorPalette),

View File

@@ -11,6 +11,10 @@ const registryApiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1&param=value2)
indexes: null
}
})

View File

@@ -1,6 +1,5 @@
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import ExecutionErrorDialogContent from '@/components/dialog/content/ExecutionErrorDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
@@ -15,6 +14,7 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
export type ConfirmationDialogType =
@@ -70,12 +70,21 @@ export const useDialogService = () => {
})
}
function showExecutionErrorDialog(
props: InstanceType<typeof ExecutionErrorDialogContent>['$props']
) {
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
error: {
exceptionType: executionError.exception_type,
exceptionMessage: executionError.exception_message,
nodeId: executionError.node_id,
nodeType: executionError.node_type,
traceback: executionError.traceback.join('\n'),
reportType: 'graphExecutionError'
}
}
dialogStore.showDialog({
key: 'global-execution-error',
component: ExecutionErrorDialogContent,
component: ErrorDialogContent,
props
})
}
@@ -174,23 +183,33 @@ export const useDialogService = () => {
error: unknown,
options: {
title?: string
errorType?: string
reportType?: string
} = {}
) {
const props =
const errorProps: {
errorMessage: string
stackTrace?: string
extensionFile?: string
} =
error instanceof Error
? parseError(error)
: {
errorMessage: String(error)
}
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
error: {
exceptionType: options.title ?? 'Unknown Error',
exceptionMessage: errorProps.errorMessage,
traceback: errorProps.stackTrace ?? t('errorDialog.noStackTrace'),
reportType: options.reportType
}
}
dialogStore.showDialog({
key: 'global-error',
component: ErrorDialogContent,
props: {
...props,
...options
}
props
})
}

View File

@@ -14,7 +14,11 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { st, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComfyNodeDef as ComfyNodeDefV2,
InputSpec,
OutputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ComfyApp, app } from '@/scripts/app'
import { $el } from '@/scripts/ui'
@@ -39,20 +43,65 @@ export const useLitegraphService = () => {
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
static comfyClass?: string
static title?: string
static nodeData?: ComfyNodeDefV1 & ComfyNodeDefV2
static category?: string
static comfyClass: string
static title: string
static category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
constructor(title?: string) {
// @ts-expect-error fixme ts strict error
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
constructor(title: string) {
super(title)
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
this.serialize_widgets = true
extensionService.invokeExtensionsAsync('nodeCreated', this)
}
const nodeMinSize = { width: 1, height: 1 }
// Process inputs using V2 schema
for (const [inputName, inputSpec] of Object.entries(nodeDef.inputs)) {
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const [inputName, inputSpec] of Object.entries(inputs)) {
const inputType = inputSpec.type
const nameKey = `nodeDefs.${normalizeI18nKey(nodeDef.name)}.inputs.${normalizeI18nKey(inputName)}.name`
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets[inputType]
if (widgetConstructor) {
@@ -68,29 +117,25 @@ export const useLitegraphService = () => {
) ?? {}
if (widget) {
const fallback = widget.label ?? inputName
widget.label = st(nameKey, fallback)
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
if (inputSpec.isOptional) {
widget.options.inputIsOptional = true
}
if (inputSpec.forceInput) {
widget.options.forceInput = true
}
if (inputSpec.defaultInput) {
widget.options.defaultInput = true
}
if (inputSpec.advanced) {
widget.advanced = true
}
if (inputSpec.hidden) {
widget.hidden = true
}
Object.assign(widget.options, {
inputIsOptional: inputSpec.isOptional,
forceInput: inputSpec.forceInput,
defaultInput: inputSpec.defaultInput,
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
nodeMinSize.width = Math.max(nodeMinSize.width, minWidth)
nodeMinSize.height = Math.max(nodeMinSize.height, minHeight)
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
} else {
// Node connection inputs
const shapeOptions = inputSpec.isOptional
@@ -103,17 +148,17 @@ export const useLitegraphService = () => {
})
}
}
}
// Process outputs using V2 schema
for (const output of nodeDef.outputs) {
const outputName = output.name
const outputType = output.type
const outputIsList = output.is_list
const shapeOptions = outputIsList
? { shape: LiteGraph.GRID_SHAPE }
: {}
const nameKey = `nodeDefs.${normalizeI18nKey(nodeDef.name)}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(outputType)}`
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
@@ -121,20 +166,20 @@ export const useLitegraphService = () => {
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
outputType !== outputName
? st(nameKey, outputName)
: st(typeKey, outputName)
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(outputName, outputType, outputOptions)
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
s[0] = Math.max(nodeMinSize.width, s[0] * 1.5)
s[1] = Math.max(nodeMinSize.height, s[1])
s[0] = Math.max(this.#initialMinSize.width, s[0] * 1.5)
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
this.serialize_widgets = true
extensionService.invokeExtensionsAsync('nodeCreated', this)
}
configure(data: any) {

View File

@@ -1,287 +1,96 @@
import Fuse, { FuseSearchOptions, IFuseOptions } from 'fuse.js'
import _ from 'lodash'
import { FuseSearchOptions } from 'fuse.js'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export type SearchAuxScore = number[]
interface ExtraSearchOptions {
matchWildcards?: boolean
}
export class FuseSearch<T> {
public readonly fuse: Fuse<T>
public readonly keys: string[]
public readonly data: T[]
public readonly advancedScoring: boolean
constructor(
data: T[],
options?: IFuseOptions<T>,
createIndex: boolean = true,
advancedScoring: boolean = false
) {
this.data = data
// @ts-expect-error fixme ts strict error
this.keys = (options.keys ?? []) as string[]
this.advancedScoring = advancedScoring
const index =
createIndex && options?.keys
? Fuse.createIndex(options.keys, data)
: undefined
this.fuse = new Fuse(data, options, index)
}
public search(query: string, options?: FuseSearchOptions): T[] {
const fuseResult = !query
? this.data.map((x) => ({ item: x, score: 0 }))
: this.fuse.search(query, options)
if (!this.advancedScoring) {
return fuseResult.map((x) => x.item)
}
const aux = fuseResult
.map((x) => ({
item: x.item,
// @ts-expect-error fixme ts strict error
scores: this.calcAuxScores(query.toLocaleLowerCase(), x.item, x.score)
}))
.sort((a, b) => this.compareAux(a.scores, b.scores))
return aux.map((x) => x.item)
}
public calcAuxScores(query: string, entry: T, score: number): SearchAuxScore {
let values: string[] = []
if (!this.keys.length) values = [entry as string]
// @ts-expect-error fixme ts strict error
else values = this.keys.map((x) => entry[x])
const scores = values.map((x) => this.calcAuxSingle(query, x, score))
let result = scores.sort(this.compareAux)[0]
const deprecated = values.some((x) =>
x.toLocaleLowerCase().includes('deprecated')
)
result[0] += deprecated && result[0] != 0 ? 5 : 0
// @ts-expect-error fixme ts strict error
if (entry['postProcessSearchScores']) {
// @ts-expect-error fixme ts strict error
result = entry['postProcessSearchScores'](result) as SearchAuxScore
}
return result
}
public calcAuxSingle(
query: string,
item: string,
score: number
): SearchAuxScore {
const itemWords = item
.split(/ |\b|(?<=[a-z])(?=[A-Z])|(?=[A-Z][a-z])/)
.map((x) => x.toLocaleLowerCase())
const queryParts = query.split(' ')
item = item.toLocaleLowerCase()
let main = 9
let aux1 = 0
let aux2 = 0
if (item == query) {
main = 0
} else if (item.startsWith(query)) {
main = 1
aux2 = item.length
} else if (itemWords.includes(query)) {
main = 2
aux1 = item.indexOf(query) + item.length * 0.5
aux2 = item.length
} else if (item.includes(query)) {
main = 3
aux1 = item.indexOf(query) + item.length * 0.5
aux2 = item.length
} else if (queryParts.every((x) => itemWords.includes(x))) {
const indexes = queryParts.map((x) => itemWords.indexOf(x))
const min = Math.min(...indexes)
const max = Math.max(...indexes)
main = 4
aux1 = max - min + max * 0.5 + item.length * 0.5
aux2 = item.length
} else if (queryParts.every((x) => item.includes(x))) {
const min = Math.min(...queryParts.map((x) => item.indexOf(x)))
const max = Math.max(...queryParts.map((x) => item.indexOf(x) + x.length))
main = 5
aux1 = max - min + max * 0.5 + item.length * 0.5
aux2 = item.length
}
const lengthPenalty =
0.2 *
(1 -
Math.min(item.length, query.length) /
Math.max(item.length, query.length))
return [main, aux1, aux2, score + lengthPenalty]
}
public compareAux(a: SearchAuxScore, b: SearchAuxScore) {
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i]) {
return a[i] - b[i]
}
}
return a.length - b.length
}
}
export type FilterAndValue<T = string> = [NodeFilter<T>, T]
export class NodeFilter<FilterOptionT = string> {
public readonly fuseSearch: FuseSearch<FilterOptionT>
constructor(
public readonly id: string,
public readonly name: string,
public readonly invokeSequence: string,
public readonly longInvokeSequence: string,
public readonly nodeOptions:
| FilterOptionT[]
| ((node: ComfyNodeDefImpl) => FilterOptionT[]),
nodeDefs: ComfyNodeDefImpl[],
options?: IFuseOptions<FilterOptionT>
) {
this.fuseSearch = new FuseSearch(this.getAllNodeOptions(nodeDefs), options)
}
public getNodeOptions(node: ComfyNodeDefImpl): FilterOptionT[] {
return this.nodeOptions instanceof Function
? this.nodeOptions(node)
: this.nodeOptions
}
public getAllNodeOptions(nodeDefs: ComfyNodeDefImpl[]): FilterOptionT[] {
// @ts-expect-error fixme ts strict error
return [
...new Set(
// @ts-expect-error fixme ts strict error
nodeDefs.reduce((acc, nodeDef) => {
return [...acc, ...this.getNodeOptions(nodeDef)]
}, [])
)
]
}
public matches(
node: ComfyNodeDefImpl,
value: FilterOptionT,
extraOptions?: ExtraSearchOptions
): boolean {
const matchWildcards = extraOptions?.matchWildcards !== false
if (matchWildcards && value === '*') {
return true
}
const options = this.getNodeOptions(node)
return (
options.includes(value) ||
(matchWildcards && _.some(options, (option) => option === '*'))
)
}
}
import { FuseFilter, FuseFilterWithValue, FuseSearch } from '@/utils/fuseUtil'
export class NodeSearchService {
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
public readonly nodeFilters: NodeFilter<string>[]
public readonly inputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly outputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly nodeCategoryFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly nodeSourceFilter: FuseFilter<ComfyNodeDefImpl, string>
constructor(data: ComfyNodeDefImpl[]) {
this.nodeFuseSearch = new FuseSearch(
data,
{
this.nodeFuseSearch = new FuseSearch(data, {
fuseOptions: {
keys: ['name', 'display_name'],
includeScore: true,
threshold: 0.3,
shouldSort: false,
useExtendedSearch: true
},
true,
true
)
createIndex: true,
advancedScoring: true
})
const filterSearchOptions = {
const fuseOptions = {
includeScore: true,
threshold: 0.3,
shouldSort: true
}
const inputTypeFilter = new NodeFilter<string>(
/* id */ 'input',
/* name */ 'Input Type',
/* invokeSequence */ 'i',
/* longInvokeSequence */ 'input',
(node) => Object.values(node.inputs).map((input) => input.type),
data,
filterSearchOptions
)
this.inputTypeFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
id: 'input',
name: 'Input Type',
invokeSequence: 'i',
getItemOptions: (node) =>
Object.values(node.inputs).map((input) => input.type),
fuseOptions
})
const outputTypeFilter = new NodeFilter<string>(
/* id */ 'output',
/* name */ 'Output Type',
/* invokeSequence */ 'o',
/* longInvokeSequence */ 'output',
(node) => node.outputs.map((output) => output.type),
data,
filterSearchOptions
)
this.outputTypeFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
id: 'output',
name: 'Output Type',
invokeSequence: 'o',
getItemOptions: (node) => node.outputs.map((output) => output.type),
fuseOptions
})
const nodeCategoryFilter = new NodeFilter<string>(
/* id */ 'category',
/* name */ 'Category',
/* invokeSequence */ 'c',
/* longInvokeSequence */ 'category',
(node) => [node.category],
data,
filterSearchOptions
)
this.nodeCategoryFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
id: 'category',
name: 'Category',
invokeSequence: 'c',
getItemOptions: (node) => [node.category],
fuseOptions
})
const nodeSourceFilter = new NodeFilter<string>(
/* id */ 'source',
/* name */ 'Source',
/* invokeSequence */ 's',
/* longInvokeSequence */ 'source',
(node) => [node.nodeSource.displayText],
data,
filterSearchOptions
)
this.nodeFilters = [
inputTypeFilter,
outputTypeFilter,
nodeCategoryFilter,
nodeSourceFilter
]
}
public endsWithFilterStartSequence(query: string): boolean {
return query.endsWith(':')
this.nodeSourceFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
id: 'source',
name: 'Source',
invokeSequence: 's',
getItemOptions: (node) => [node.nodeSource.displayText],
fuseOptions
})
}
public searchNode(
query: string,
filters: FilterAndValue<string>[] = [],
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[] = [],
options?: FuseSearchOptions,
extraOptions?: ExtraSearchOptions
extraOptions: {
matchWildcards?: boolean
} = {}
): ComfyNodeDefImpl[] {
const { matchWildcards = true } = extraOptions
const wildcard = matchWildcards ? '*' : undefined
const matchedNodes = this.nodeFuseSearch.search(query)
const results = matchedNodes.filter((node) => {
return _.every(filters, (filterAndValue) => {
const [filter, value] = filterAndValue
return filter.matches(node, value, extraOptions)
return filters.every((filterAndValue) => {
const { filterDef, value } = filterAndValue
return filterDef.matches(node, value, { wildcard })
})
})
return options?.limit ? results.slice(0, options.limit) : results
}
public getFilterById(id: string): NodeFilter<string> | undefined {
return this.nodeFilters.find((filter) => filter.id === id)
get nodeFilters(): FuseFilter<ComfyNodeDefImpl, string>[] {
return [
this.inputTypeFilter,
this.outputTypeFilter,
this.nodeCategoryFilter,
this.nodeSourceFilter
]
}
}

View File

@@ -3,10 +3,7 @@ import type { SerialisableGraph, Vector2 } from '@comfyorg/litegraph'
import { toRaw } from 'vue'
import { t } from '@/i18n'
import {
ComfyWorkflowJSON,
WorkflowJSON04
} from '@/schemas/comfyWorkflowSchema'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
@@ -15,7 +12,6 @@ import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { migrateLegacyRerouteNodes } from '@/utils/migration/migrateReroute'
import { useDialogService } from './dialogService'
@@ -167,7 +163,8 @@ export const useWorkflowService = () => {
workflow,
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: loadFromRemote
}
)
}
@@ -328,10 +325,7 @@ export const useWorkflowService = () => {
) => {
const loadedWorkflow = await workflow.load()
const data = loadedWorkflow.initialState
const workflowJSON =
data.version === 0.4
? migrateLegacyRerouteNodes(data as WorkflowJSON04)
: data
const workflowJSON = data
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.

View File

@@ -1,6 +1,7 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
@@ -20,6 +21,7 @@ import {
* Store for state of installed node packs
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const { t } = useI18n()
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
@@ -136,14 +138,17 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
async (params: InstallPackParams, signal?: AbortSignal) => {
if (!params.id) return
let actionDescription = 'Installing'
let actionDescription = t('g.installing')
if (installedPacksIds.value.has(params.id)) {
const installedPack = installedPacks.value[params.id]
if (installedPack && installedPack.ver !== params.selected_version) {
actionDescription = `Changing version from ${installedPack.ver} to ${params.selected_version}:`
actionDescription = t('manager.changingVersion', {
from: installedPack.ver,
to: params.selected_version
})
} else {
actionDescription = 'Enabling'
actionDescription = t('g.enabling')
}
}
@@ -157,14 +162,14 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
installPack.clear()
installPack.cancel()
const task = () => managerService.uninstallPack(params, signal)
enqueueTask(withLogs(task, `Uninstalling ${params.id}`))
enqueueTask(withLogs(task, t('manager.uninstalling', { id: params.id })))
}
const updatePack = useCachedRequest<ManagerPackInfo, void>(
async (params: ManagerPackInfo, signal?: AbortSignal) => {
updateAllPacks.cancel()
const task = () => managerService.updatePack(params, signal)
enqueueTask(withLogs(task, `Updating ${params.id}`))
enqueueTask(withLogs(task, t('g.updating', { id: params.id })))
},
{ maxSize: 1 }
)
@@ -172,14 +177,14 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
const updateAllPacks = useCachedRequest<UpdateAllPacksParams, void>(
async (params: UpdateAllPacksParams, signal?: AbortSignal) => {
const task = () => managerService.updateAllPacks(params, signal)
enqueueTask(withLogs(task, 'Updating all packs'))
enqueueTask(withLogs(task, t('manager.updatingAllPacks')))
},
{ maxSize: 1 }
)
const disablePack = (params: ManagerPackInfo, signal?: AbortSignal) => {
const task = () => managerService.disablePack(params, signal)
enqueueTask(withLogs(task, `Disabling ${params.id}`))
enqueueTask(withLogs(task, t('g.disabling', { id: params.id })))
}
const getInstalledPackVersion = (packId: string) => {

View File

@@ -1,3 +1,5 @@
import QuickLRU from '@alloc/quick-lru'
import { partition } from 'lodash'
import { defineStore } from 'pinia'
import { useCachedRequest } from '@/composables/useCachedRequest'
@@ -5,7 +7,7 @@ import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components, operations } from '@/types/comfyRegistryTypes'
const PACK_LIST_CACHE_SIZE = 20
const PACK_BY_ID_CACHE_SIZE = 50
const PACK_BY_ID_CACHE_SIZE = 64
type NodePack = components['schemas']['Node']
type ListPacksParams = operations['listAllNodes']['parameters']['query']
@@ -14,12 +16,21 @@ type ListPacksResult =
type ComfyNode = components['schemas']['ComfyNode']
type GetPackByIdPath = operations['getNode']['parameters']['path']['nodeId']
const isNodePack = (pack: NodePack | undefined): pack is NodePack => {
return pack !== undefined && 'id' in pack
}
/**
* Store for managing remote custom nodes
*/
export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
const registryService = useComfyRegistryService()
let getPacksByIdController: AbortController | null = null
const getPacksByIdCache = new QuickLRU<NodePack['id'], NodePack>({
maxSize: PACK_BY_ID_CACHE_SIZE
})
/**
* Get a list of all node packs from the registry
*/
@@ -39,6 +50,41 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
{ maxSize: PACK_BY_ID_CACHE_SIZE }
)
/**
* Get a list of packs by their IDs from the registry
*/
const getPacksByIds = async (ids: NodePack['id'][]): Promise<NodePack[]> => {
const [cachedPacksIds, uncachedPacksIds] = partition(ids, (id) =>
getPacksByIdCache.has(id)
)
const resolvedPacks = cachedPacksIds
.map((id) => getPacksByIdCache.get(id))
.filter(isNodePack)
if (uncachedPacksIds.length) {
getPacksByIdController = new AbortController()
const uncachedPacks = await registryService.listAllPacks(
{
node_id: uncachedPacksIds.filter(
(id): id is string => id !== undefined
)
},
getPacksByIdController.signal
)
const { nodes = [] } = uncachedPacks ?? {}
nodes.forEach((pack) => {
if (pack?.id) {
getPacksByIdCache.set(pack.id, pack)
resolvedPacks.push(pack)
}
})
}
return resolvedPacks
}
/**
* Get the node definitions for a pack
*/
@@ -63,11 +109,16 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
getNodeDefs.cancel()
listAllPacks.cancel()
getPackById.cancel()
getPacksByIdController?.abort()
}
return {
listAllPacks,
getPackById,
getPacksByIds: {
call: getPacksByIds,
cancel: () => getPacksByIdController?.abort()
},
getNodeDefs,
clearCache,

View File

@@ -3,29 +3,40 @@ import { computed, ref } from 'vue'
import type {
ExecutedWsMessage,
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionStartWsMessage,
NodeError,
ProgressWsMessage
} from '@/schemas/apiSchema'
import type {
ComfyNode,
ComfyWorkflowJSON
ComfyWorkflowJSON,
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { ComfyWorkflow } from './workflowStore'
export interface QueuedPrompt {
nodes: Record<string, boolean>
/**
* The nodes that are queued to be executed. The key is the node id and the
* value is a boolean indicating if the node has been executed.
*/
nodes: Record<NodeId, boolean>
/**
* The workflow that is queued to be executed
*/
workflow?: ComfyWorkflow
}
export const useExecutionStore = defineStore('execution', () => {
const clientId = ref<string | null>(null)
const activePromptId = ref<string | null>(null)
const queuedPrompts = ref<Record<string, QueuedPrompt>>({})
const executingNodeId = ref<string | null>(null)
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const executingNodeId = ref<NodeId | null>(null)
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
@@ -47,11 +58,7 @@ export const useExecutionStore = defineStore('execution', () => {
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
_executingNodeProgress.value
? Math.round(
(_executingNodeProgress.value.value /
_executingNodeProgress.value.max) *
100
)
? _executingNodeProgress.value.value / _executingNodeProgress.value.max
: null
)
@@ -75,7 +82,7 @@ export const useExecutionStore = defineStore('execution', () => {
if (!activePrompt.value) return 0
const total = totalNodesToExecute.value
const done = nodesExecuted.value
return Math.round((done / total) * 100)
return done / total
})
function bindExecutionEvents() {
@@ -91,6 +98,10 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('executing', handleExecuting as EventListener)
api.addEventListener('progress', handleProgress as EventListener)
api.addEventListener('status', handleStatus as EventListener)
api.addEventListener(
'execution_error',
handleExecutionError as EventListener
)
}
function unbindExecutionEvents() {
@@ -106,9 +117,14 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('executing', handleExecuting as EventListener)
api.removeEventListener('progress', handleProgress as EventListener)
api.removeEventListener('status', handleStatus as EventListener)
api.removeEventListener(
'execution_error',
handleExecutionError as EventListener
)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
lastExecutionError.value = null
activePromptId.value = e.detail.prompt_id
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
}
@@ -125,7 +141,7 @@ export const useExecutionStore = defineStore('execution', () => {
activePrompt.value.nodes[e.detail.node] = true
}
function handleExecuting(e: CustomEvent<ExecutingWsMessage>) {
function handleExecuting(e: CustomEvent<NodeId | null>) {
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -135,8 +151,8 @@ export const useExecutionStore = defineStore('execution', () => {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
}
executingNodeId.value = e.detail ? String(e.detail) : null
if (!executingNodeId.value) {
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
@@ -157,6 +173,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
lastExecutionError.value = e.detail
}
function storePrompt({
nodes,
id,
@@ -185,14 +205,49 @@ export const useExecutionStore = defineStore('execution', () => {
return {
isIdle,
clientId,
/**
* The id of the prompt that is currently being executed
*/
activePromptId,
/**
* The queued prompts
*/
queuedPrompts,
/**
* The node errors from the previous execution.
*/
lastNodeErrors,
/**
* The error from the previous execution.
*/
lastExecutionError,
/**
* The id of the node that is currently being executed
*/
executingNodeId,
/**
* The prompt that is currently being executed
*/
activePrompt,
/**
* The total number of nodes to execute
*/
totalNodesToExecute,
/**
* The number of nodes that have been executed
*/
nodesExecuted,
/**
* The progress of the execution
*/
executionProgress,
/**
* The node that is currently being executed
*/
executingNode,
/**
* The progress of the executing node (if the node reports progress)
*/
executingNodeProgress,
bindExecutionEvents,
unbindExecutionEvents,

View File

@@ -14,19 +14,19 @@ import type {
ComfyNodeDef as ComfyNodeDefV1,
ComfyOutputTypesSpec as ComfyOutputSpecV1
} from '@/schemas/nodeDefSchema'
import {
NodeSearchService,
type SearchAuxScore
} from '@/services/nodeSearchService'
import { NodeSearchService } from '@/services/nodeSearchService'
import {
type NodeSource,
NodeSourceType,
getNodeSource
} from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { FuseSearchable, SearchAuxScore } from '@/utils/fuseUtil'
import { buildTree } from '@/utils/treeUtil'
export class ComfyNodeDefImpl implements ComfyNodeDefV1, ComfyNodeDefV2 {
export class ComfyNodeDefImpl
implements ComfyNodeDefV1, ComfyNodeDefV2, FuseSearchable
{
// ComfyNodeDef fields (V1)
readonly name: string
readonly display_name: string
@@ -140,6 +140,18 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
python_module: 'nodes',
description: 'Primitive values like numbers, strings, and booleans.'
},
Reroute: {
name: 'Reroute',
display_name: 'Reroute',
category: 'utils',
input: { required: { '': ['*', {}] }, optional: {} },
output: ['*'],
output_name: [''],
output_is_list: [false],
output_node: false,
python_module: 'nodes',
description: 'Reroute the connection to another node.'
},
Note: {
name: 'Note',
display_name: 'Note',

View File

@@ -186,7 +186,7 @@ declare module '@comfyorg/litegraph' {
* We should remove this hacky solution once we have a proper solution.
*/
interface INodeOutputSlot {
widget?: IWidget
widget?: { name: string; [key: symbol]: unknown }
}
}

View File

@@ -0,0 +1,81 @@
import type { ISerialisedGraph } from '@comfyorg/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
export interface ErrorReportData {
exceptionType: string
exceptionMessage: string
systemStats: SystemStats
serverLogs: string
workflow: ISerialisedGraph
traceback?: string
nodeId?: NodeId
nodeType?: string
}
/**
* Generate a report for an error.
* @param error - The error to report.
* @returns The report.
*/
export function generateErrorReport(error: ErrorReportData): string {
// The default JSON workflow has about 3000 characters.
const MAX_JSON_LENGTH = 20000
const workflowJSONString = JSON.stringify(error.workflow)
const workflowText =
workflowJSONString.length > MAX_JSON_LENGTH
? 'Workflow too large. Please manually upload the workflow from local file system.'
: workflowJSONString
const systemStats = error.systemStats
return `
# ComfyUI Error Report
${
error
? `## Error Details
- **Node ID:** ${error.nodeId || 'N/A'}
- **Node Type:** ${error.nodeType || 'N/A'}
- **Exception Type:** ${error.exceptionType || 'N/A'}
- **Exception Message:** ${error.exceptionMessage || 'N/A'}
## Stack Trace
\`\`\`
${error.traceback || 'No stack trace available'}
\`\`\``
: ''
}
## System Information
- **ComfyUI Version:** ${systemStats.system.comfyui_version}
- **Arguments:** ${systemStats.system.argv.join(' ')}
- **OS:** ${systemStats.system.os}
- **Python Version:** ${systemStats.system.python_version}
- **Embedded Python:** ${systemStats.system.embedded_python}
- **PyTorch Version:** ${systemStats.system.pytorch_version}
## Devices
${systemStats.devices
.map(
(device) => `
- **Name:** ${device.name}
- **Type:** ${device.type}
- **VRAM Total:** ${device.vram_total}
- **VRAM Free:** ${device.vram_free}
- **Torch VRAM Total:** ${device.torch_vram_total}
- **Torch VRAM Free:** ${device.torch_vram_free}
`
)
.join('\n')}
## Logs
\`\`\`
${error.serverLogs}
\`\`\`
## Attached Workflow
Please make sure that workflow does not contain any sensitive information such as API keys or passwords.
\`\`\`
${workflowText}
\`\`\`
## Additional Context
(Please add any additional context or steps to reproduce the error here)
`
}

212
src/utils/fuseUtil.ts Normal file
View File

@@ -0,0 +1,212 @@
import Fuse, { FuseOptionKey, FuseSearchOptions, IFuseOptions } from 'fuse.js'
export type SearchAuxScore = number[]
export interface FuseFilterWithValue<T, O = string> {
filterDef: FuseFilter<T, O>
value: O
}
export class FuseFilter<T, O = string> {
public readonly fuseSearch: FuseSearch<O>
/** The unique identifier for the filter. */
public readonly id: string
/** The name of the filter for display purposes. */
public readonly name: string
/** The sequence of characters to invoke the filter. */
public readonly invokeSequence: string
/** A function that returns the options for the filter. */
public readonly getItemOptions: (item: T) => O[]
constructor(
data: T[],
options: {
id: string
name: string
invokeSequence: string
getItemOptions: (item: T) => O[]
fuseOptions?: IFuseOptions<O>
}
) {
this.id = options.id
this.name = options.name
this.invokeSequence = options.invokeSequence
this.getItemOptions = options.getItemOptions
this.fuseSearch = new FuseSearch(this.getAllNodeOptions(data), {
fuseOptions: options.fuseOptions
})
}
public getAllNodeOptions(data: T[]): O[] {
const options = new Set<O>()
for (const item of data) {
for (const option of this.getItemOptions(item)) {
options.add(option)
}
}
return Array.from(options)
}
public matches(
item: T,
value: O,
extraOptions: {
wildcard?: O
} = {}
): boolean {
const { wildcard } = extraOptions
if (wildcard && value === wildcard) {
return true
}
const options = this.getItemOptions(item)
return (
options.includes(value) ||
(!!wildcard && options.some((option) => option === wildcard))
)
}
}
export interface FuseSearchable {
postProcessSearchScores: (scores: SearchAuxScore) => SearchAuxScore
}
function isFuseSearchable(item: any): item is FuseSearchable {
return 'postProcessSearchScores' in item
}
/**
* A wrapper around Fuse.js that provides a more type-safe API.
*/
export class FuseSearch<T> {
public readonly fuse: Fuse<T>
public readonly keys: FuseOptionKey<T>[]
public readonly data: T[]
public readonly advancedScoring: boolean
constructor(
data: T[],
options: {
fuseOptions?: IFuseOptions<T>
createIndex?: boolean
advancedScoring?: boolean
}
) {
const { fuseOptions, createIndex = true, advancedScoring = false } = options
this.data = data
this.keys = fuseOptions?.keys ?? []
this.advancedScoring = advancedScoring
const index =
createIndex && this.keys.length
? Fuse.createIndex(this.keys, data)
: undefined
this.fuse = new Fuse(data, fuseOptions, index)
}
public search(query: string, options?: FuseSearchOptions): T[] {
const fuseResult = !query
? this.data.map((x) => ({ item: x, score: 0 }))
: this.fuse.search(query, options)
if (!this.advancedScoring) {
return fuseResult.map((x) => x.item)
}
const aux = fuseResult
.map((x) => ({
item: x.item,
scores: this.calcAuxScores(
query.toLocaleLowerCase(),
x.item,
x.score ?? 0
)
}))
.sort((a, b) => this.compareAux(a.scores, b.scores))
return aux.map((x) => x.item)
}
public calcAuxScores(query: string, entry: T, score: number): SearchAuxScore {
let values: string[] = []
if (typeof entry === 'string') {
values = [entry]
} else if (typeof entry === 'object' && entry !== null) {
values = this.keys
.map((x) => entry[x as keyof T])
.filter((x) => typeof x === 'string') as string[]
}
const scores = values.map((x) => this.calcAuxSingle(query, x, score))
let result = scores.sort(this.compareAux)[0]
const deprecated = values.some((x) =>
x.toLocaleLowerCase().includes('deprecated')
)
result[0] += deprecated && result[0] !== 0 ? 5 : 0
if (isFuseSearchable(entry)) {
result = entry.postProcessSearchScores(result)
}
return result
}
public calcAuxSingle(
query: string,
item: string,
score: number
): SearchAuxScore {
const itemWords = item
.split(/ |\b|(?<=[a-z])(?=[A-Z])|(?=[A-Z][a-z])/)
.map((x) => x.toLocaleLowerCase())
const queryParts = query.split(' ')
item = item.toLocaleLowerCase()
let main = 9
let aux1 = 0
let aux2 = 0
if (item == query) {
main = 0
} else if (item.startsWith(query)) {
main = 1
aux2 = item.length
} else if (itemWords.includes(query)) {
main = 2
aux1 = item.indexOf(query) + item.length * 0.5
aux2 = item.length
} else if (item.includes(query)) {
main = 3
aux1 = item.indexOf(query) + item.length * 0.5
aux2 = item.length
} else if (queryParts.every((x) => itemWords.includes(x))) {
const indexes = queryParts.map((x) => itemWords.indexOf(x))
const min = Math.min(...indexes)
const max = Math.max(...indexes)
main = 4
aux1 = max - min + max * 0.5 + item.length * 0.5
aux2 = item.length
} else if (queryParts.every((x) => item.includes(x))) {
const min = Math.min(...queryParts.map((x) => item.indexOf(x)))
const max = Math.max(...queryParts.map((x) => item.indexOf(x) + x.length))
main = 5
aux1 = max - min + max * 0.5 + item.length * 0.5
aux2 = item.length
}
const lengthPenalty =
0.2 *
(1 -
Math.min(item.length, query.length) /
Math.max(item.length, query.length))
return [main, aux1, aux2, score + lengthPenalty]
}
public compareAux(a: SearchAuxScore, b: SearchAuxScore) {
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i]) {
return a[i] - b[i]
}
}
return a.length - b.length
}
}

View File

@@ -1,8 +1,10 @@
import type { ColorOption } from '@comfyorg/litegraph'
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
import type { ComfyNode } from '@/schemas/comfyWorkflowSchema'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
videoContainer: HTMLElement | undefined
@@ -70,3 +72,40 @@ export function executeWidgetsCallback(
}
}
}
/**
* Creates a copy of the nodes with non-serialized widget values removed
*/
export function filterSerializedWidgetValues(
nodes: ComfyNode[],
graph: LGraph
): ComfyNode[] {
if (!graph) return nodes
return nodes.map((node) => {
if (!node.widgets_values) return node
const graphNode = graph.getNodeById(node.id)
if (!graphNode?.widgets) return node
const filteredNode = { ...node }
const serializedValues = []
for (let i = 0; i < graphNode.widgets.length; i++) {
const widget = graphNode.widgets[i]
// Skip if widget is not serialized
if (!widget.options || widget.options.serialize !== false) {
const value = Array.isArray(node.widgets_values)
? node.widgets_values[i]
: node.widgets_values[widget.name || i.toString()]
serializedValues.push(
typeof value === 'object' && value !== null ? value.value : value
)
}
}
filteredNode.widgets_values = serializedValues
return filteredNode
})
}

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'
import type {
ComfyLink,
ComfyLinkObject,
ComfyNode,
NodeId,
Reroute,
@@ -17,20 +17,26 @@ type LinkExtension = {
parentId: number
}
type RerouteEntry = {
reroute: Reroute
rerouteNode: RerouteNode
}
/**
* Identifies all legacy Reroute nodes in a workflow
*/
function findLegacyRerouteNodes(workflow: WorkflowJSON04): RerouteNode[] {
export function findLegacyRerouteNodes(
workflow: WorkflowJSON04
): RerouteNode[] {
return workflow.nodes.filter(
(node) => node.type === 'Reroute'
) as RerouteNode[]
}
/**
* Checks if the workflow has no native reroutes
*/
export function noNativeReroutes(workflow: WorkflowJSON04): boolean {
return (
!workflow.extra?.reroutes?.length && !workflow.extra?.linkExtensions?.length
)
}
/**
* Gets the center position of a node
*/
@@ -38,156 +44,269 @@ function getNodeCenter(node: ComfyNode): [number, number] {
return [node.pos[0] + node.size[0] / 2, node.pos[1] + node.size[1] / 2]
}
/**
* Creates native reroute points from legacy Reroute nodes
*/
export function createReroutePoints(
rerouteNodes: RerouteNode[]
): Map<NodeId, RerouteEntry> {
const rerouteMap = new Map<NodeId, RerouteEntry>()
let rerouteIdCounter = 1
rerouteNodes.forEach((node) => {
const rerouteId = rerouteIdCounter++
rerouteMap.set(node.id, {
reroute: {
id: rerouteId,
pos: getNodeCenter(node),
linkIds: []
},
rerouteNode: node
})
})
return rerouteMap
}
/**
* Creates new links and link extensions for the migrated workflow
*/
export function createNewLinks(
workflow: WorkflowJSON04,
rerouteMap: Map<NodeId, RerouteEntry>
): {
nodes: ComfyNode[]
reroutes: Reroute[]
links: ComfyLink[]
class ConversionContext {
nodeById: Record<NodeId, ComfyNode>
linkById: Record<number, ComfyLinkObject>
rerouteById: Record<number, Reroute>
rerouteByNodeId: Record<NodeId, Reroute>
linkExtensions: LinkExtension[]
} {
const nodeById = _.keyBy(
workflow.nodes.filter((node) => node.type !== 'Reroute').map(_.cloneDeep),
'id'
)
const links: ComfyLink[] = []
const linkExtensions: LinkExtension[] = []
const rerouteMapByRerouteId = new Map<number, RerouteEntry>(
Array.from(rerouteMap.values()).map((entry) => [entry.reroute.id, entry])
)
const linksMap = new Map<number, ComfyLink>(
Array.from(workflow.links).map((link) => [link[0], link])
)
/** Reroutes that has at least a valid link pass through it */
validReroutes: Set<Reroute>
// Process each link in the workflow
for (const link of workflow.links) {
const [
linkId,
sourceNodeId,
_sourceSlot,
targetNodeId,
_targetSlot,
_dataType
] = link
#rerouteIdCounter = 0
// Check if this link connects to or from a reroute node
const sourceEntry = rerouteMap.get(sourceNodeId)
const targetEntry = rerouteMap.get(targetNodeId)
const sourceIsReroute = !!sourceEntry
const targetIsReroute = !!targetEntry
constructor(public workflow: WorkflowJSON04) {
this.nodeById = _.keyBy(workflow.nodes.map(_.cloneDeep), 'id')
this.linkById = _.keyBy(
workflow.links.map((l) => ({
id: l[0],
origin_id: l[1],
origin_slot: l[2],
target_id: l[3],
target_slot: l[4],
type: l[5]
})),
'id'
)
if (!sourceIsReroute && !targetIsReroute) {
// If neither end is a reroute, keep the link as is
links.push(link)
} else if (sourceIsReroute && !targetIsReroute) {
// This is a link from a reroute node to a regular node
linkExtensions.push({
id: linkId,
parentId: sourceEntry.reroute.id
})
} else if (sourceIsReroute && targetIsReroute) {
targetEntry.reroute.parentId = sourceEntry.reroute.id
const reroutes = findLegacyRerouteNodes(workflow).map((node, index) => ({
nodeId: node.id,
id: index + 1,
pos: getNodeCenter(node),
linkIds: []
}))
this.#rerouteIdCounter = reroutes.length + 1
this.rerouteByNodeId = _.keyBy(reroutes, 'nodeId')
this.rerouteById = _.keyBy(reroutes, 'id')
this.linkExtensions = []
this.validReroutes = new Set()
}
/**
* Gets the chain of reroute nodes leading to the given node
*/
#getRerouteChain(node: RerouteNode): RerouteNode[] {
const nodes: RerouteNode[] = []
let currentNode: RerouteNode = node
while (currentNode?.type === 'Reroute') {
nodes.push(currentNode)
const inputLink: ComfyLinkObject | undefined =
this.linkById[currentNode.inputs?.[0]?.link ?? 0]
if (!inputLink) {
break
}
currentNode = this.nodeById[inputLink.origin_id] as RerouteNode
}
return nodes
}
#connectRerouteChain(rerouteNodes: RerouteNode[]): Reroute[] {
const reroutes = rerouteNodes.map((node) => this.rerouteByNodeId[node.id])
for (const reroute of reroutes) {
this.validReroutes.add(reroute)
}
for (let i = 0; i < rerouteNodes.length - 1; i++) {
const to = reroutes[i]
const from = reroutes[i + 1]
to.parentId = from.id
}
return reroutes
}
#createNewLink(
startingLink: ComfyLinkObject,
endingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
if (rerouteNodes.length === 0) {
throw new Error('No reroute nodes found')
}
const reroute = this.rerouteByNodeId[rerouteNodes[0].id]
this.linkExtensions.push({
id: endingLink.id,
parentId: reroute.id
})
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
reroute.linkIds ??= []
reroute.linkIds.push(endingLink.id)
delete reroute.floating
}
return {
id: endingLink.id,
origin_id: startingLink.origin_id,
origin_slot: startingLink.origin_slot,
target_id: endingLink.target_id,
target_slot: endingLink.target_slot,
type: endingLink.type
}
}
// Populate linkIds on reroute nodes
// Remove all partially connected reroutes
const validLinkExtensions: LinkExtension[] = []
const validReroutes: Set<Reroute> = new Set()
for (const linkExtension of linkExtensions) {
let entry = rerouteMapByRerouteId.get(linkExtension.parentId)
const chainedReroutes: Reroute[] = []
while (entry) {
const reroute = entry.reroute
reroute.linkIds ??= []
reroute.linkIds.push(linkExtension.id)
chainedReroutes.push(reroute)
if (reroute.parentId) {
entry = rerouteMapByRerouteId.get(reroute.parentId)
} else {
// Last reroute in the chain
const rerouteNode = entry.rerouteNode
const rerouteInputLink = linksMap.get(
rerouteNode?.inputs?.[0]?.link ?? -1
)
const rerouteOutputLink = linksMap.get(linkExtension.id)
if (rerouteInputLink && rerouteOutputLink) {
const [_, sourceNodeId, sourceSlot] = rerouteInputLink
const [linkId, __, ___, targetNodeId, targetSlot, dataType] =
rerouteOutputLink
links.push([
linkId,
sourceNodeId,
sourceSlot,
targetNodeId,
targetSlot,
dataType
])
validLinkExtensions.push(linkExtension)
chainedReroutes.forEach((reroute) => validReroutes.add(reroute))
// Update source node's output slot's link ids to point to the new link.
const sourceNode = nodeById[sourceNodeId]
if (!sourceNode) {
throw new Error(
`Corrupted workflow: Source node ${sourceNodeId} not found`
)
}
const outputSlot = sourceNode.outputs?.[sourceSlot]
if (!outputSlot) {
throw new Error(
`Corrupted workflow: Output slot ${sourceSlot} not found`
)
}
outputSlot.links = outputSlot.links?.map((l) =>
l === rerouteInputLink[0] ? linkId : l
)
#createNewInputFloatingLink(
endingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
if (!reroute.linkIds?.length) {
reroute.floating = {
slotType: 'input'
}
entry = undefined
}
}
return {
id: this.#rerouteIdCounter++,
origin_id: -1,
origin_slot: -1,
target_id: endingLink.target_id,
target_slot: endingLink.target_slot,
type: endingLink.type,
parentId: reroutes[0].id
}
}
return {
nodes: Object.values(nodeById),
links,
linkExtensions: validLinkExtensions,
reroutes: Array.from(validReroutes)
#createNewOutputFloatingLink(
startingLink: ComfyLinkObject,
rerouteNodes: RerouteNode[]
): ComfyLinkObject {
const reroutes = this.#connectRerouteChain(rerouteNodes)
for (const reroute of reroutes) {
if (!reroute.linkIds?.length) {
reroute.floating = {
slotType: 'output'
}
}
}
return {
id: this.#rerouteIdCounter++,
origin_id: startingLink.origin_id,
origin_slot: startingLink.origin_slot,
target_id: -1,
target_slot: -1,
type: startingLink.type,
parentId: reroutes[0].id
}
}
#reconnectLinks(nodes: ComfyNode[], links: ComfyLinkObject[]): void {
// Remove all existing links on sockets
for (const node of nodes) {
for (const input of node.inputs ?? []) {
input.link = null
}
for (const output of node.outputs ?? []) {
output.links = []
}
}
const nodesById = _.keyBy(nodes, 'id')
// Reconnect the links
for (const link of links) {
const sourceNode = nodesById[link.origin_id]
sourceNode.outputs![link.origin_slot]!.links!.push(link.id)
const targetNode = nodesById[link.target_id]
targetNode.inputs![link.target_slot]!.link = link.id
}
}
migrateReroutes(): WorkflowJSON04 {
const links: ComfyLinkObject[] = []
const floatingLinks: ComfyLinkObject[] = []
const endingLinks: ComfyLinkObject[] = []
for (const link of Object.values(this.linkById)) {
const sourceIsReroute = !!this.rerouteByNodeId[link.origin_id]
const targetIsReroute = !!this.rerouteByNodeId[link.target_id]
// Process links that are not connected to reroute nodes
if (!sourceIsReroute && !targetIsReroute) {
links.push(link)
} else if (sourceIsReroute && !targetIsReroute) {
endingLinks.push(link)
}
}
for (const endingLink of endingLinks) {
const endingRerouteNode = this.nodeById[
endingLink.origin_id
] as RerouteNode
const rerouteNodes = this.#getRerouteChain(endingRerouteNode)
const startingLink =
this.linkById[
rerouteNodes[rerouteNodes.length - 1]?.inputs?.[0]?.link ?? -1
]
if (startingLink) {
// Valid link found, create a new link
links.push(this.#createNewLink(startingLink, endingLink, rerouteNodes))
} else {
// Floating link found, create a new floating link
floatingLinks.push(
this.#createNewInputFloatingLink(endingLink, rerouteNodes)
)
}
}
const floatingEndingRerouteNodes = Object.keys(this.rerouteByNodeId)
.map((nodeId) => this.nodeById[nodeId] as RerouteNode)
.filter((rerouteNode) => {
const output = rerouteNode.outputs?.[0]
if (!output) return false
return !output.links?.length
})
for (const rerouteNode of floatingEndingRerouteNodes) {
const rerouteNodes = this.#getRerouteChain(rerouteNode)
const startingLink =
this.linkById[
rerouteNodes[rerouteNodes.length - 1]?.inputs?.[0]?.link ?? -1
]
if (startingLink) {
floatingLinks.push(
this.#createNewOutputFloatingLink(startingLink, rerouteNodes)
)
}
}
const nodes = Object.values(this.nodeById).filter(
(node) => node.type !== 'Reroute'
)
this.#reconnectLinks(nodes, links)
return {
...this.workflow,
nodes,
links: links.map((link) => [
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot,
link.type
]),
floatingLinks: floatingLinks.length > 0 ? floatingLinks : undefined,
extra: {
...this.workflow.extra,
reroutes: Array.from(this.validReroutes).map(
(reroute) => _.omit(reroute, 'nodeId') as Reroute
),
linkExtensions: this.linkExtensions
}
}
}
}
@@ -213,20 +332,6 @@ export const migrateLegacyRerouteNodes = (
newWorkflow.extra = {}
}
// Create native reroute points
const rerouteMap = createReroutePoints(legacyRerouteNodes)
// Create new links and link extensions
const { nodes, links, linkExtensions, reroutes } = createNewLinks(
workflow,
rerouteMap
)
// Update the workflow
newWorkflow.links = links
newWorkflow.nodes = nodes
newWorkflow.extra.reroutes = reroutes
newWorkflow.extra.linkExtensions = linkExtensions
return newWorkflow
const context = new ConversionContext(newWorkflow)
return context.migrateReroutes()
}

View File

@@ -14,6 +14,7 @@
</div>
<GlobalToast />
<RerouteMigrationToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<BrowserTabTitle />
<MenuHamburger />
@@ -31,6 +32,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import TopMenubar from '@/components/topbar/TopMenubar.vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -232,7 +234,7 @@ const onGraphReady = () => {
// Node defs now available after comfyApp.setup.
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
useNodeDefStore().nodeSearchService.searchNode('')
},
{ timeout: 1000 }
)

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { useChainCallback } from '@/composables/functional/useChainCallback'
describe('useChainCallback', () => {
it('preserves "this" context in original callback and chained callbacks', () => {
class TestClass {
value = 'test'
constructor() {
this.method = useChainCallback(this.method, function (this: TestClass) {
expect(this.value).toBe('test')
})
}
method() {
expect(this.value).toBe('test')
}
}
const instance = new TestClass()
instance.method()
})
it('handles undefined original callback', () => {
const context = { value: 'test' }
const chainedFn = useChainCallback(
undefined,
function (this: typeof context) {
expect(this.value).toBe('test')
}
)
chainedFn.call(context)
})
it('passes arguments to all callbacks', () => {
const originalCalls: number[] = []
const chainedCalls: number[] = []
const original = function (this: unknown, num: number) {
originalCalls.push(num)
}
const chained = function (this: unknown, num: number) {
chainedCalls.push(num)
}
const chainedFn = useChainCallback(original, chained)
chainedFn(42)
expect(originalCalls).toEqual([42])
expect(chainedCalls).toEqual([42])
})
})

View File

@@ -64,12 +64,14 @@ const EXAMPLE_NODE_DEFS: ComfyNodeDefImpl[] = (
describe('nodeSearchService', () => {
it('searches with input filter', () => {
const service = new NodeSearchService(EXAMPLE_NODE_DEFS)
const inputFilter = service.getFilterById('input')
// @ts-expect-error fixme ts strict error
expect(service.searchNode('L', [[inputFilter, 'LATENT']])).toHaveLength(1)
const inputFilter = service.inputTypeFilter
expect(
service.searchNode('L', [{ filterDef: inputFilter, value: 'LATENT' }])
).toHaveLength(1)
// Wildcard should match all.
// @ts-expect-error fixme ts strict error
expect(service.searchNode('L', [[inputFilter, '*']])).toHaveLength(2)
expect(
service.searchNode('L', [{ filterDef: inputFilter, value: '*' }])
).toHaveLength(2)
expect(service.searchNode('L')).toHaveLength(2)
})
})

View File

@@ -13,6 +13,17 @@ vi.mock('@/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
interface EnabledDisabledTestCase {
desc: string
installed: Record<string, ManagerPackInstalled>

View File

@@ -26,6 +26,38 @@ const mockNodePack: components['schemas']['Node'] = {
}
}
const mockNodePack2: components['schemas']['Node'] = {
id: 'test-pack-id-2',
name: 'Test Pack 2',
description: 'A second test node pack',
downloads: 1000,
publisher: {
id: 'test-publisher',
name: 'Test Publisher'
},
latest_version: {
id: 'test-version',
version: '1.0.0',
createdAt: '2023-01-01T00:00:00Z'
}
}
const mockNodePack3: components['schemas']['Node'] = {
id: 'test-pack-id-3',
name: 'Test Pack 3',
description: 'A third test node pack',
downloads: 1000,
publisher: {
id: 'test-publisher',
name: 'Test Publisher'
},
latest_version: {
id: 'test-version',
version: '1.0.0',
createdAt: '2023-01-01T00:00:00Z'
}
}
const mockListResult: operations['listAllNodes']['responses'][200]['content']['application/json'] =
{
nodes: [mockNodePack],
@@ -48,7 +80,32 @@ describe('useComfyRegistryStore', () => {
mockRegistryService = {
isLoading: ref(false),
error: ref(null),
listAllPacks: vi.fn().mockResolvedValue(mockListResult),
listAllPacks: vi.fn().mockImplementation((params) => {
// If node_id is provided, return specific nodes
if (params.node_id) {
return Promise.resolve({
nodes: params.node_id
.map((id: string) => {
switch (id) {
case 'test-pack-id':
return mockNodePack
case 'test-pack-id-2':
return mockNodePack2
case 'test-pack-id-3':
return mockNodePack3
default:
return null
}
})
.filter(Boolean),
total: params.node_id.length,
page: 1,
limit: 10
})
}
// Otherwise return paginated results
return Promise.resolve(mockListResult)
}),
getPackById: vi.fn().mockResolvedValue(mockNodePack)
}
@@ -117,4 +174,16 @@ describe('useComfyRegistryStore', () => {
expect(result).toBeNull()
})
it('should fetch packs by IDs', async () => {
const store = useComfyRegistryStore()
const packIds = ['test-pack-id', 'test-pack-id-2', 'test-pack-id-3']
const result = await store.getPacksByIds.call(packIds)
expect(result).toEqual([mockNodePack, mockNodePack2, mockNodePack3])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith(
{ node_id: packIds },
expect.any(Object) // abort signal
)
})
})

View File

@@ -14,22 +14,24 @@ describe('migrateReroute', () => {
return JSON.parse(fileContent) as WorkflowJSON04
}
it.each(['branching.json', 'single_connected.json', 'floating.json'])(
'should correctly migrate %s',
(fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
)
it.each([
'branching.json',
'single_connected.json',
'floating.json',
'floating_branch.json'
])('should correctly migrate %s', (fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
)
// Migrate the workflow
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Migrate the workflow
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Compare with snapshot
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
}
)
// Compare with snapshot
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
})
})
})

View File

@@ -0,0 +1,253 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 33,
"type": "Reroute",
"pos": [
492.768310546875,
274.761962890625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 41
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
40
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 32,
"type": "Reroute",
"pos": [
362.8304138183594,
275.12872314453125
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 39
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
41,
42
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
39
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 34,
"type": "Reroute",
"pos": [
490.8152770996094,
364.4836730957031
],
"size": [
75,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 42
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": null
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
}
],
"links": [
[
39,
4,
2,
32,
0,
"*"
],
[
40,
33,
0,
12,
1,
"VAE"
],
[
41,
32,
0,
33,
0,
"*"
],
[
42,
32,
0,
34,
0,
"*"
]
],
"floatingLinks": [
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*"
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": []
},
"version": 0.4
}

View File

@@ -35,8 +35,8 @@
"type": "VAE",
"slot_index": 2,
"links": [
13,
21
21,
34
]
}
],
@@ -77,7 +77,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {
@@ -115,7 +115,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {

View File

@@ -34,10 +34,7 @@
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
13,
31
]
"links": []
}
],
"properties": {
@@ -72,14 +69,14 @@
{
"name": "vae",
"type": "VAE",
"link": 21
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {
@@ -101,8 +98,51 @@
200.06933385457722
]
},
"reroutes": [],
"reroutes": [
{
"id": 1,
"pos": [
547.5,
293
],
"linkIds": [],
"floating": {
"slotType": "input"
}
},
{
"id": 2,
"pos": [
438.99267578125,
291.96600341796875
],
"linkIds": [],
"floating": {
"slotType": "output"
}
}
],
"linkExtensions": []
},
"version": 0.4
"version": 0.4,
"floatingLinks": [
{
"id": 7,
"origin_id": -1,
"origin_slot": -1,
"target_id": 12,
"target_slot": 1,
"type": "VAE",
"parentId": 1
},
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 2
}
]
}

View File

@@ -0,0 +1,166 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
40
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
40,
4,
2,
12,
1,
"VAE"
]
],
"floatingLinks": [
{
"id": 4,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 3
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": [
{
"id": 40,
"parentId": 1
}
],
"reroutes": [
{
"id": 1,
"pos": [
530.268310546875,
287.761962890625
],
"linkIds": [
40
],
"parentId": 2
},
{
"id": 2,
"pos": [
400.3304138183594,
288.12872314453125
],
"linkIds": [
40
]
},
{
"id": 3,
"pos": [
528.3152770996094,
377.4836730957031
],
"linkIds": [],
"parentId": 2,
"floating": {
"slotType": "output"
}
}
]
},
"version": 0.4
}

View File

@@ -76,7 +76,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": []
}
],
"properties": {