Compare commits

..

95 Commits

Author SHA1 Message Date
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
Comfy Org PR Bot
4702cd18ce 1.15.3 (#3246)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-26 16:33:13 -04:00
Chenlei Hu
00d281c7fa [Test] Add playwright tests (#3245)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-26 16:32:42 -04:00
Chenlei Hu
3e25e08b10 [Bug] Fix broken output link id during reroute migration (#3244) 2025-03-26 16:00:54 -04:00
Comfy Org PR Bot
1d66d6d7d3 [chore] Update litegraph to 0.11.3 (#3242)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-26 15:25:17 -04:00
dependabot[bot]
cae5bbe86f Bump vite from 5.4.14 to 5.4.15 (#3243)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 15:25:08 -04:00
Christian Byrne
4d35d937cf [Manager] Fix build env vars (#3238) 2025-03-26 11:03:03 -04:00
Comfy Org PR Bot
60afa5cf6c [chore] Update Comfy Registry API types from comfy-api@d7be0da (#3239)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-03-26 02:48:38 -07:00
Comfy Org PR Bot
a1a33c8c9b 1.15.2 (#3237)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-25 22:07:08 -04:00
Chenlei Hu
9988fb8f1e [Bug] Fix selection toolbox select+drag (#3235)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-25 15:27:25 -04:00
Chenlei Hu
0518b170d3 [i18n] Add spanish translation (#3233)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-25 14:34:44 -04:00
Chenlei Hu
562cd7ea70 [TS] Fix type errors in app.ts (#3232) 2025-03-25 13:02:55 -04:00
Chenlei Hu
d3c64d404b [Bug] Fix selection toolbox position on pasted node (#3231) 2025-03-25 10:51:04 -04:00
Chenlei Hu
24dcaa7f72 [i18n] Translate toast messages (#3228)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 22:22:17 -04:00
Comfy Org PR Bot
6c18781663 1.15.1 (#3227)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-24 22:01:15 -04:00
Chenlei Hu
b6988e8d5c Report file load error via toast (#3226)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 22:01:07 -04:00
Chenlei Hu
ae64721555 Implement load workflow error dialog in Vue (#3225)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 21:00:50 -04:00
Terry Jia
abe65e58a0 [3d] add support to upload texture (#3224)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 16:58:45 -04:00
Comfy Org PR Bot
a1cfb68116 [chore] Update litegraph to 0.11.2 (#3223)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 16:09:40 -04:00
MohammadAboulEla
5bee36a73e Enable/Disable the drawing of image size (#3200) 2025-03-24 13:47:55 -04:00
Chenlei Hu
a4b0f5ab5e [Bug] Fix widget placeholder rendering (#3215)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 23:02:35 -04:00
filtered
f8a2c90138 Fix drag new link from reroute to widget (#3214) 2025-03-23 22:29:51 -04:00
Chenlei Hu
27c252f74a Render placeholder rect for DOM widgets when zoomed out (#3213)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 21:35:58 -04:00
Comfy Org PR Bot
7e26cffb26 [chore] Update litegraph to 0.11.1 (#3212)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-23 21:02:31 -04:00
niboshi
cb8354bfce Show error message on drop error (#3189) 2025-03-23 20:52:55 -04:00
Comfy Org PR Bot
d5ebd7b7cb [chore] Update litegraph to 0.11.0 (#3211)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-24 11:36:24 +11:00
Comfy Org PR Bot
845d045991 1.15.0 (#3210)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-23 19:13:09 -04:00
Chenlei Hu
c3154fe297 [Reroute] Clean floating reroutes in reroute migration (#3209) 2025-03-23 18:40:39 -04:00
Christian Byrne
f90d61fad5 [Style] Remove box shadow on template dialog sidenav (#3208) 2025-03-23 16:14:34 -04:00
Christian Byrne
17834459a1 Fix parsing workflows from 3d outputs on Windows (#3196) 2025-03-23 11:42:57 -07:00
Chenlei Hu
564c4d557f Migrate legacy reroute to litegraph native reroute (#3151)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-22 18:56:35 -04:00
filtered
f852639758 Fix slot types added multiple times (#3194) 2025-03-23 09:01:12 +11:00
Comfy Org PR Bot
eae538b08e [chore] Update litegraph to 0.11.0-2 (#3193)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 08:01:25 +11:00
Dr.Lt.Data
d23108433e refine locales/ko (#3185) 2025-03-22 15:06:10 -04:00
Comfy Org PR Bot
e6e7449ece [chore] Update litegraph to 0.11.0-1 (#3190)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-23 03:49:44 +11:00
Comfy Org PR Bot
22a1200bdf [chore] Update litegraph to 0.11.0-0 (#3183)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-22 10:08:41 +11:00
Chenlei Hu
ee20b63bc1 Remove unused plugin vite-plugin-static-copy (#3182) 2025-03-21 14:11:01 -04:00
Comfy Org PR Bot
0752e8b986 1.14.5 (#3181)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-21 11:07:21 -04:00
Christian Byrne
b234a68cf8 [Manager] Get node pack info on select (#3177) 2025-03-21 11:05:09 -04:00
Terry Jia
0863fda6a4 [3d] add support to export different formats (#3176)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-21 11:04:39 -04:00
Christian Byrne
8530406c3e [Manager] Set max size on custom node pack icons (#3178) 2025-03-21 11:04:23 -04:00
Christian Byrne
f7bfb6ec57 Fix Workflow Validation error when node pack 'unknown' version (#3179) 2025-03-21 11:04:06 -04:00
Comfy Org PR Bot
830933e78f Update locales for node definitions (#3174)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:44:46 -04:00
Comfy Org PR Bot
65693ed2be 1.14.4 (#3173)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:36:22 -04:00
Chenlei Hu
ed153dccd9 [Test] Fix flaky playwright tests (#3170) 2025-03-20 21:33:05 -04:00
Christian Byrne
fc7ed1bf09 Add Hunyuan3d template workflow titles i18 fields (#3171)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-03-20 21:31:42 -04:00
Christian Byrne
4dad89369a Load workflows from GLTF files (#3169)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-20 20:55:51 -04:00
Chenlei Hu
5b730517a3 [Test] Sync workflow instead of full page reload (#3168) 2025-03-20 20:42:12 -04:00
Chenlei Hu
af0bf05883 [TS] Add null checks to TreeExplorer.vue (#3166) 2025-03-20 13:08:22 -04:00
Chenlei Hu
d9e62ff860 [Vue] Use Vue 3.5 syntax for prop default value (#3165) 2025-03-20 12:40:49 -04:00
Chenlei Hu
d9ae6cb395 [TS] Use custom TreeNode type (#3164) 2025-03-20 12:03:47 -04:00
Chenlei Hu
b162963593 [TS] Fix TreeExplorerNode types (#3163) 2025-03-20 11:47:04 -04:00
Christian Byrne
c34cc301f1 [Manager] Allow cancelling registry requests by route (#3158) 2025-03-20 10:30:00 -04:00
Terry Jia
afdb94f12f [3d] refactor legacy code by using new vue style (#3161) 2025-03-20 10:28:35 -04:00
Dr.Lt.Data
cc8dc3dbfb refine locales/ko (#3162) 2025-03-20 10:27:55 -04:00
filtered
42d99fc37e Track floating link changes in undo/redo history (#3160) 2025-03-20 22:19:31 +11:00
Comfy Org PR Bot
1f03984d12 1.14.3 (#3155)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-19 22:01:08 -04:00
Terry Jia
d49815fcb4 [3d] add preview 3d for saveGlb (#3156) 2025-03-19 22:00:59 -04:00
Christian Byrne
4899a1d8f6 [Manager] Keep node previews inside info panel bounds (#3140) 2025-03-19 21:22:16 -04:00
Christian Byrne
71444d8c69 [Manager] Allow searching while in 'Missing' node packs tab (#3153) 2025-03-19 20:58:55 -04:00
Chenlei Hu
867ed4c1d7 [Schema] Update zod schema on zVector2 (#3152) 2025-03-19 20:58:13 -04:00
Christian Byrne
0c6957bfd8 [Manager] Use skeleton placeholder when loading (#3150) 2025-03-19 20:18:44 -04:00
Christian Byrne
fffce30e91 [Manager] Allow scrolling info panel while keeping the icon pinned at top (#3139) 2025-03-19 20:05:49 -04:00
Chenlei Hu
c554138887 [Develop] Remove duplicated run of tsc typecheck (#3149) 2025-03-19 14:26:22 -04:00
Chenlei Hu
5bbceea76c Downgrade vitest to 2.0.0 (#3148) 2025-03-19 14:22:44 -04:00
Christian Byrne
0f0601100f [Manager] Re-evaluate search results in installed tab when node packs change (#3144) 2025-03-19 10:34:24 -04:00
Christian Byrne
ff59245a7f [Manager] Use inject for installing button state (#3143) 2025-03-19 10:33:47 -04:00
Christian Byrne
c5af11d1ea [Manager] Remove title hover (#3142) 2025-03-19 10:30:42 -04:00
Christian Byrne
bc3e2e1597 [Manager] Auto scroll logs terminal to bottom (#3141)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-19 10:30:11 -04:00
filtered
e2a8456ff0 Remove yarn packageManager claim from package.json (#3145) 2025-03-19 19:26:35 +11:00
Christian Byrne
361c5ba930 [Manager] Fallback text on info panel (#3138)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-03-19 00:49:04 -07:00
176 changed files with 14624 additions and 1824 deletions

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'],
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, controlnet, lora.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -0,0 +1,104 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "KSampler",
"pos": {
"0": 304.3653259277344,
"1": 42.15586471557617
},
"size": [
315,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 1,
"type": "PrimitiveInt",
"pos": {
"0": 14,
"1": 43
},
"size": [
203.1999969482422,
40.368401303242536
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "value",
"type": "INT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Int"
},
"widgets_values": [10]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

Binary file not shown.

View File

@@ -214,6 +214,10 @@ export class ComfyPage {
`Failed to setup workflows directory: ${await resp.text()}`
)
}
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
}
async setupUser(username: string) {
@@ -478,6 +482,7 @@ export class ComfyPage {
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
@@ -584,11 +589,20 @@ export class ComfyPage {
await this.dragAndDrop(this.clipTextEncodeNode1InputSlot, this.emptySpace)
}
async connectEdge() {
await this.dragAndDrop(
this.loadCheckpointNodeClipOutputSlot,
this.clipTextEncodeNode1InputSlot
)
async connectEdge(
options: {
reverse?: boolean
} = {}
) {
const { reverse = false } = options
const start = reverse
? this.clipTextEncodeNode1InputSlot
: this.loadCheckpointNodeClipOutputSlot
const end = reverse
? this.loadCheckpointNodeClipOutputSlot
: this.clipTextEncodeNode1InputSlot
await this.dragAndDrop(start, end)
}
async adjustWidgetValue() {

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -309,3 +309,35 @@ test.describe('Feedback dialog', () => {
await expect(feedbackHeader).not.toBeVisible()
})
})
test.describe('Error dialog', () => {
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window['graph']
graph.configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.loadWorkflow('default')
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

@@ -5,8 +5,8 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('DOM Widget', () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('collapsed_multiline')
expect(comfyPage.page.locator('.comfy-multiline-input')).not.toBeVisible()
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
})
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {

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

View File

@@ -91,15 +91,20 @@ test.describe('Node Interaction', () => {
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
// Test both directions of edge connection.
;[{ reverse: false }, { reverse: true }].forEach(({ reverse }) => {
test(`Can disconnect/connect edge ${reverse ? 'reverse' : 'normal'}`, async ({
comfyPage
}) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge({ reverse })
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -8,7 +8,8 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm'
'workflow.webm',
'workflow.glb'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -5,14 +5,14 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node')
await comfyPage.loadWorkflow('primitive/primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
})
// When link is dropped on widget, it should automatically convert the widget
// to input.
test('Can connect to widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected')
await comfyPage.loadWorkflow('primitive/primitive_node_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
// Connect the output of the primitive node to the input of first widget of the ksampler node
@@ -23,7 +23,9 @@ test.describe('Primitive Node', () => {
})
test('Can connect to dom widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected_dom_widget')
await comfyPage.loadWorkflow(
'primitive/primitive_node_unconnected_dom_widget'
)
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const clipEncoderNode: NodeReference = await comfyPage.getNodeRefById(2)
await primitiveNode.connectWidget(0, clipEncoderNode, 0)
@@ -31,4 +33,14 @@ test.describe('Primitive Node', () => {
'primitive_node_connected_dom_widget.png'
)
})
test('Can connect to static primitive node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive/static_primitive_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'static_primitive_connected.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -321,6 +321,7 @@ test.describe('Remote COMBO Widget', () => {
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
await comfyPage.page.waitForTimeout(200)
// Verify the selected value of the widget is the first option in the refreshed list
const refreshedValue = await getWidgetValue(comfyPage, nodeName)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -30,6 +30,46 @@ test.describe('Selection Toolbox', () => {
).toBeVisible()
})
test('shows at correct position when node is pasted', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.selectNodes(['KSampler'])
await comfyPage.ctrlC()
await comfyPage.page.mouse.move(100, 100)
await comfyPage.ctrlV()
const overlayContainer = comfyPage.page.locator(
'.selection-overlay-container'
)
await expect(overlayContainer).toBeVisible()
// Verify the absolute position
const boundingBox = await overlayContainer.boundingBox()
expect(boundingBox).not.toBeNull()
// 10px offset for the pasted node
expect(Math.round(boundingBox!.x)).toBeCloseTo(90, -1) // Allow ~10px tolerance
// 30px offset of node title height
expect(Math.round(boundingBox!.y)).toBeCloseTo(60, -1)
})
test('hide when select and drag happen at the same time', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
const node = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const nodePos = await node.getPosition()
// Drag on the title of the node
await comfyPage.page.mouse.move(nodePos.x + 100, nodePos.y - 15)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(nodePos.x + 200, nodePos.y + 200)
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('.selection-overlay-container')
).not.toBeVisible()
})
test('shows border only with multiple selections', async ({ comfyPage }) => {
// Select single node
await comfyPage.selectNodes(['KSampler'])
@@ -206,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

@@ -34,7 +34,6 @@ test.describe('Workflows sidebar', () => {
'workflow1.json': 'default.json',
'workflow2.json': 'default.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -77,7 +76,6 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'single_ksampler.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -101,7 +99,6 @@ test.describe('Workflows sidebar', () => {
'bar.json': 'default.json'
}
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -230,6 +227,7 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
await comfyPage.page.waitForTimeout(200)
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
@@ -323,7 +321,7 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'
})
await comfyPage.setup()
await comfyPage.menu.workflowsTab.open()
const nodeCount = await comfyPage.getGraphNodesCount()

2
global.d.ts vendored
View File

@@ -1,6 +1,8 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
interface Navigator {
/**

View File

@@ -3,8 +3,7 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'vue-tsc --noEmit',
'tsc --noEmit'
'vue-tsc --noEmit'
]
}

604
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.14.2",
"version": "1.15.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.14.2",
"version": "1.15.6",
"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.10.9",
"@comfyorg/litegraph": "^0.11.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -77,10 +77,9 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.14",
"vite": "^5.4.15",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.1.9",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
@@ -291,6 +290,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.4.1.tgz",
@@ -465,9 +478,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.10.9",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.9.tgz",
"integrity": "sha512-ubGozxdDIVNL/MYvfCAXgiaqBfIODtp0jZeN9uzWrdHwqUy9ZkLt/7/q7G4nGpNcEoShbMu7EK4VPH3WRmNQ7A==",
"version": "0.11.7",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.11.7.tgz",
"integrity": "sha512-ftz9QOD53ZVLLyu3m5Hi+FKHp+y7k+wHvrsgvX/X6a0l840kGlnOI+W3em8yajIuOIgqbjDsu5ZeXZn2l1FUpw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -1260,6 +1273,19 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@@ -1330,19 +1356,6 @@
"node": ">=18"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -2496,6 +2509,13 @@
}
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tiptap/core": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.4.tgz",
@@ -3414,79 +3434,28 @@
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.0.tgz",
"integrity": "sha512-5BSfZ0+dAVmC6uPF7s+TcKx0i7oyYHb1WQQL5gg6G2c+Qkaa5BNrdRM74sxDfUIZUgYCr6bfCqmJp+X5bfcNxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
"@vitest/spy": "2.0.0",
"@vitest/utils": "2.0.0",
"chai": "^5.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.0.tgz",
"integrity": "sha512-OovFmlkfRmdhevbWImBUtn9IEM+CKac8O+m9p6W9jTATGVBnDJQ6/jb1gpHyWxsu0ALi5f+TLi+Uyst7AAimMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"@vitest/utils": "2.0.0",
"pathe": "^1.1.2"
},
"funding": {
@@ -3494,48 +3463,59 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.0.tgz",
"integrity": "sha512-B520cSAQwtWgocPpARadnNLslHCxFs5tf7SG2TT96qz+SZgsXqcB1xI3w3/S9kUzdqykEKrMLvW+sIIpMcuUdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
"magic-string": "^0.30.10",
"pathe": "^1.1.2",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.0.tgz",
"integrity": "sha512-0g7ho4wBK09wq8iNZFtUcQZcUcbPmbLWFotL0GXel0fvk5yPi4nTEKpIvZ+wA5eRyqPUCIfIUl10AWzLr67cmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
"tinyspy": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.0.tgz",
"integrity": "sha512-t0jbx8VugWEP6A29NbyfQKVU68Vo6oUw0iX3a8BwO3nrZuivfHcFO4Y5UsqXlplX+83P9UaqEvC2YQhspC0JSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
"diff-sequences": "^29.6.3",
"estree-walker": "^3.0.3",
"loupe": "^3.1.1",
"pretty-format": "^29.7.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.10",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz",
@@ -4107,6 +4087,19 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -4518,9 +4511,9 @@
}
},
"node_modules/chai": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
"integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5242,6 +5235,16 @@
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -5473,13 +5476,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -5989,14 +5985,70 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true
},
"node_modules/expect-type": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
"integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"license": "Apache-2.0",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=12.0.0"
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/extend": {
@@ -6371,6 +6423,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz",
@@ -6633,6 +6698,16 @@
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@@ -7196,6 +7271,19 @@
"optional": true,
"peer": true
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-upper-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
@@ -8036,140 +8124,6 @@
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/lint-staged/node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/lint-staged/node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/lint-staged/node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lint-staged/node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.3.tgz",
@@ -9556,6 +9510,35 @@
"node": ">=0.10.0"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -10292,6 +10275,21 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
@@ -10653,6 +10651,13 @@
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
@@ -11515,6 +11520,19 @@
"node": ">=0.10.0"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -11767,16 +11785,6 @@
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
@@ -12551,9 +12559,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
"integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==",
"version": "5.4.15",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12611,16 +12619,16 @@
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.0.tgz",
"integrity": "sha512-jZtezmjcgZTkMisIi68TdY8w/PqPTxK2pbfTU9/4Gqus1K3AVZqkwH0z7Vshe3CD6mq9rJq8SpqmuefDMIqkfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"debug": "^4.3.5",
"pathe": "^1.1.2",
"picocolors": "^1.0.1",
"vite": "^5.0.0"
},
"bin": {
@@ -12714,51 +12722,31 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/vite-plugin-static-copy": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-1.0.6.tgz",
"integrity": "sha512-3uSvsMwDVFZRitqoWHj0t4137Kz7UynnJeq1EZlRW7e25h2068fyIZX4ORCCOAkfp1FklGxJNVJBkBOD+PZIew==",
"dev": true,
"dependencies": {
"chokidar": "^3.5.3",
"fast-glob": "^3.2.11",
"fs-extra": "^11.1.0",
"picocolors": "^1.0.0"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.0.tgz",
"integrity": "sha512-NvccE2tZhIoPSq3o3AoTBmItwhHNjzIxvOgfdzILIscyzSGOtw2+A1d/JJbS86HDVbc6TS5HnckQuCgTfp0HDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"@ampproject/remapping": "^2.3.0",
"@vitest/expect": "2.0.0",
"@vitest/runner": "2.0.0",
"@vitest/snapshot": "2.0.0",
"@vitest/spy": "2.0.0",
"@vitest/utils": "2.0.0",
"chai": "^5.1.1",
"debug": "^4.3.5",
"execa": "^8.0.1",
"magic-string": "^0.30.10",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"picocolors": "^1.0.1",
"std-env": "^3.7.0",
"tinybench": "^2.8.0",
"tinypool": "^1.0.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
"vite-node": "2.0.0",
"why-is-node-running": "^2.2.2"
},
"bin": {
"vitest": "vitest.mjs"
@@ -12772,8 +12760,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"@vitest/browser": "2.0.0",
"@vitest/ui": "2.0.0",
"happy-dom": "*",
"jsdom": "*"
},

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.14.2",
"version": "1.15.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,7 +13,7 @@
"build": "npm run typecheck && vite build",
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit && tsc --noEmit",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx playwright test",
@@ -60,10 +60,9 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.14",
"vite": "^5.4.15",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.1.9",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
@@ -72,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.10.9",
"@comfyorg/litegraph": "^0.11.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -104,6 +103,5 @@
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39"
}
}

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,7 +1,6 @@
<template>
<div
class="batch-count"
:class="props.class"
v-tooltip.bottom="{
value: $t('menu.batchCount'),
showDelay: 600
@@ -41,14 +40,6 @@ import { computed } from 'vue'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
interface Props {
class?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1

View File

@@ -18,7 +18,7 @@
:src="src"
@error="handleImageError"
class="comfy-image-main"
:class="[...classArray]"
:class="classProp"
:alt="alt"
/>
</span>
@@ -29,37 +29,24 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
src: string
class?: string | string[] | object
contain: boolean
alt?: string
}>(),
{
contain: false,
alt: 'Image content'
}
)
const {
src,
class: classProp,
contain = false,
alt = 'Image content'
} = defineProps<{
src: string
class?: any
contain?: boolean
alt?: string
}>()
const imageBroken = ref(false)
const handleImageError = () => {
imageBroken.value = true
}
const classArray = computed(() => {
if (Array.isArray(props.class)) {
return props.class
} else if (typeof props.class === 'string') {
return props.class.split(' ')
} else if (typeof props.class === 'object') {
// @ts-expect-error fixme ts strict error
return Object.keys(props.class).filter((key) => props.class[key])
}
return []
})
</script>
<style scoped>

View File

@@ -1,6 +1,6 @@
<template>
<div class="editable-text">
<span v-if="!props.isEditing">
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
@@ -27,17 +27,13 @@
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
interface EditableTextProps {
const { modelValue, isEditing = false } = defineProps<{
modelValue: string
isEditing?: boolean
}
const props = withDefaults(defineProps<EditableTextProps>(), {
isEditing: false
})
}>()
const emit = defineEmits(['update:modelValue', 'edit'])
const inputValue = ref<string>(props.modelValue)
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const blurInputElement = () => {
@@ -48,10 +44,10 @@ const finishEditing = () => {
emit('edit', inputValue.value)
}
watch(
() => props.isEditing,
() => isEditing,
(newVal) => {
if (newVal) {
inputValue.value = props.modelValue
inputValue.value = modelValue
nextTick(() => {
if (!inputRef.value) return
const fileName = inputValue.value.includes('.')

View File

@@ -13,9 +13,9 @@
<template>
<Button
class="relative p-button-icon-only"
:outlined="props.outlined"
:severity="props.severity"
:disabled="active || props.disabled"
:outlined="outlined"
:severity="severity"
:disabled="active || disabled"
@click="(event) => $emit('refresh', event)"
>
<span
@@ -34,16 +34,15 @@ import ProgressSpinner from 'primevue/progressspinner'
import { VueSeverity } from '@/types/primeVueTypes'
// Properties
interface Props {
outlined?: boolean
const {
disabled,
outlined = true,
severity = 'secondary'
} = defineProps<{
disabled?: boolean
outlined?: boolean
severity?: VueSeverity
}
const props = withDefaults(defineProps<Props>(), {
outlined: true,
severity: 'secondary'
})
}>()
// Model
const active = defineModel<boolean>({ required: true })

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center" :class="props.class">
<div class="flex items-center">
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
@@ -9,19 +9,17 @@
<script setup lang="ts">
import Divider from 'primevue/divider'
interface Props {
const {
text,
position = 'left',
align = 'center',
type = 'solid',
layout = 'horizontal'
} = defineProps<{
text: string
class?: string
position?: 'left' | 'right'
align?: 'left' | 'center' | 'right' | 'top' | 'bottom'
type?: 'solid' | 'dashed' | 'dotted'
layout?: 'horizontal' | 'vertical'
}
const props = withDefaults(defineProps<Props>(), {
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
})
}>()
</script>

View File

@@ -170,32 +170,38 @@ const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
// @ts-expect-error fixme ts strict error
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
// @ts-expect-error fixme ts strict error
command: () => renameCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
// @ts-expect-error fixme ts strict error
command: () => deleteCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
deleteCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
].map((menuItem) => ({
].map((menuItem: MenuItem) => ({
...menuItem,
// @ts-expect-error fixme ts strict error
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
command: menuItem.command
? wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
: undefined
}))
)
@@ -223,15 +229,15 @@ const wrapCommandWithErrorHandler = (
}
defineExpose({
renameCommand,
deleteCommand,
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
// @ts-expect-error fixme ts strict error
addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey))
const targetNode = findNodeByKey(renderedRoot.value, targetNodeKey)
if (targetNode) {
addFolderCommand(targetNode)
}
}
})
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="comfy-error-report flex flex-col gap-4">
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="title"
:message="error.exceptionMessage"
/>
<template v-if="error.extensionFile">
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
<br />
<span class="font-bold">{{ error.extensionFile }}</span>
</template>
<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"
:title="$t('issueReport.submitErrorReport')"
:error-type="error.reportType ?? 'unknownError'"
:extra-fields="[stackTraceField]"
:tags="{
exceptionMessage: error.exceptionMessage,
nodeType: error.nodeType ?? 'UNKNOWN'
}"
/>
<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 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 { 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 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 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

@@ -20,7 +20,7 @@
>
<div v-for="(panel, index) in taskPanels" :key="index">
<Panel
:expanded="expandedPanels[index] || false"
:expanded="collapsedPanels[index] || false"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
@@ -28,11 +28,11 @@
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span v-show="expandedPanels[index]" class="text-muted">
<span class="text-muted">
{{
index === taskPanels.length - 1
? 'In progress'
: 'Completed '
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
@@ -41,9 +41,9 @@
<template #toggleicon>
<Button
:icon="
expandedPanels[index]
? 'pi pi-chevron-down'
: 'pi pi-chevron-right'
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
text
class="text-neutral-300"
@@ -51,11 +51,17 @@
/>
</template>
<div
:ref="
index === taskPanels.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
@@ -86,26 +92,68 @@ import {
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const expandedPanels = ref<Record<number, boolean>>({})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
expandedPanels.value[index] = !expandedPanels.value[index]
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const scrollToBottom = () => {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
whenever(() => isExpanded.value, scrollToBottom)
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
expandedPanels.value = {}
scrollToBottom()
scrollContentToBottom()
})
onBeforeUnmount(() => {

View File

@@ -157,9 +157,7 @@ const categories = computed<SettingTreeNode[]>(() =>
].map((node) => ({
...node,
translatedLabel: t(
// @ts-expect-error fixme ts strict error
`settingsCategories.${normalizeI18nKey(node.label)}`,
// @ts-expect-error fixme ts strict error
node.label
)
}))
@@ -177,16 +175,12 @@ onMounted(() => {
})
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
// @ts-expect-error fixme ts strict error
return (
[...(category.children ?? [])]
// @ts-expect-error fixme ts strict error
.sort((a, b) => a.label.localeCompare(b.label))
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group)
}))
)
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group)
}))
}
const searchQuery = ref<string>('')

View File

@@ -0,0 +1,176 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -35,9 +35,9 @@
<div class="flex-1 overflow-auto">
<div
v-if="isLoading"
class="flex justify-center items-center h-full"
class="w-full h-full overflow-auto scrollbar-hide"
>
<ProgressSpinner />
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
@@ -56,12 +56,7 @@
<VirtualGrid
:items="resultsWithKeys"
:buffer-rows="3"
:gridStyle="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
}"
:gridStyle="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
@@ -96,9 +91,9 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -109,11 +104,13 @@ import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.v
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
@@ -126,6 +123,14 @@ enum ManagerTab {
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
} as const
const {
isSmallScreen,
@@ -183,44 +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 getMissingPacks = () => filterMissingPacks(getInWorkflowResults())
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 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([isInstalledTab, installedPacks], () => {
if (!isInstalledTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterInstalledPack(searchResults.value)
} else if (!installedPacks.value.length) {
startFetchInstalled()
} else {
displayPacks.value = installedPacks.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) {
@@ -231,19 +244,20 @@ const onResultsChange = () => {
displayPacks.value = filterWorkflowPack(searchResults.value)
break
case ManagerTab.Missing:
displayPacks.value = filterMissingPacks(searchResults.value)
displayPacks.value = filterMissingPacks(
filterWorkflowPack(searchResults.value)
)
break
default:
displayPacks.value = searchResults.value
}
}
whenever(selectedTab, onTabChange)
watch(searchResults, onResultsChange, { flush: 'pre' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
const isLoading = computed(() => {
if (isSearchLoading.value)
return searchResults.value.length === 0 || isInitialLoad.value
if (isSearchLoading.value) return searchResults.value.length === 0
if (selectedTab.value?.id === ManagerTab.Installed) {
return isLoadingInstalled.value
}
@@ -253,7 +267,7 @@ const isLoading = computed(() => {
) {
return isLoadingWorkflow.value
}
return false
return isInitialLoad.value
})
const resultsWithKeys = computed(
@@ -269,6 +283,27 @@ const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
return comfyManagerStore.installedPacksIds?.size
case ManagerTab.Workflow:
return workflowPacks.value?.length
case ManagerTab.Missing:
return workflowPacks.value?.filter?.(
(pack) => !comfyManagerStore.isPackInstalled(pack.id)
)?.length
default:
return searchResults.value.length
}
}
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
@@ -303,4 +338,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
}
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
if (hasMultipleSelections.value) return
const data = await getPackById.call(selectedNodePack.value.id)
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
}
})
onUnmounted(() => {
getPackById.cancel()
})
</script>

View File

@@ -6,12 +6,12 @@
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="isExecuted"
:disabled="isInstalling"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<template v-if="isExecuted">
<template v-if="isInstalling">
{{ loadingMessage ?? $t('g.loading') }}
</template>
<template v-else>
@@ -23,7 +23,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { inject, ref } from 'vue'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
const {
label,
@@ -43,10 +45,10 @@ defineOptions({
inheritAttrs: false
})
const isExecuted = ref(false)
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isExecuted.value = true
isInstalling.value = true
emit('action')
}
</script>

View File

@@ -1,8 +1,10 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
<div class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar">
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
@@ -44,7 +46,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
@@ -54,6 +57,7 @@ import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoP
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
interface InfoItem {
@@ -66,6 +70,14 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { t, d, n } = useI18n()
@@ -94,9 +106,6 @@ const infoItems = computed<InfoItem[]>(() => [
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full">
<div v-if="nodePacks?.length" class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<InfoPanelHeader :node-packs="nodePacks">
<InfoPanelHeader :node-packs>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
@@ -24,6 +24,9 @@
</div>
</div>
</div>
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
<script setup lang="ts">

View File

@@ -9,7 +9,10 @@
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
<NodePreview
:node-def="placeholderNodeDef"
class="!text-[.625rem] !min-w-full"
/>
</div>
</div>
</template>

View File

@@ -42,7 +42,6 @@
>
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
@@ -52,7 +51,6 @@
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>

View File

@@ -2,7 +2,7 @@
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>

View File

@@ -0,0 +1,19 @@
<template>
<div :style="gridStyle">
<PackCardSkeleton v-for="n in skeletonCardCount" :key="n" />
</div>
</template>
<script setup lang="ts">
import PackCardSkeleton from '@/components/dialog/content/manager/skeleton/PackCardSkeleton.vue'
const { skeletonCardCount = 12, gridStyle } = defineProps<{
skeletonCardCount?: number
gridStyle: {
display: string
gridTemplateColumns: string
padding: string
gap: string
}
}>()
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div
class="rounded-lg border shadow-sm h-full overflow-hidden flex flex-col"
data-virtual-grid-item
>
<!-- Card header - flush with top, approximately 15% of height -->
<div class="w-full px-4 py-3 flex justify-between items-center border-b">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem"></Skeleton>
</div>
<Skeleton width="5rem" height="1rem" class="ml-2"></Skeleton>
</div>
<Skeleton width="4rem" height="1.75rem" borderRadius="0.75rem"></Skeleton>
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="flex-shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" borderRadius="0.5rem"></Skeleton>
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2"></Skeleton>
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="95%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="90%" height="0.75rem"></Skeleton>
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton
width="4rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
<Skeleton
width="5rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center border-t">
<Skeleton width="4rem" height="0.8rem"></Skeleton>
<Skeleton width="6rem" height="0.8rem"></Skeleton>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -0,0 +1,79 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import GridSkeleton from '../GridSkeleton.vue'
import PackCardSkeleton from '../PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(GridSkeleton, {
props: {
gridStyle: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
},
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
stubs: {
PackCardSkeleton: true
}
}
})
}
it('renders with default props', () => {
const wrapper = mountComponent()
expect(wrapper.exists()).toBe(true)
})
it('applies the provided grid style', () => {
const customGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '1rem',
gap: '1rem'
}
const wrapper = mountComponent({
props: { gridStyle: customGridStyle }
})
const gridElement = wrapper.element
expect(gridElement.style.display).toBe('grid')
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(gridElement.style.padding).toBe('1rem')
expect(gridElement.style.gap).toBe('1rem')
})
it('renders the specified number of skeleton cards', async () => {
const cardCount = 5
const wrapper = mountComponent({
props: { skeletonCardCount: cardCount }
})
await nextTick()
const skeletonCards = wrapper.findAllComponents(PackCardSkeleton)
expect(skeletonCards.length).toBe(5)
})
})

View File

@@ -15,15 +15,10 @@ import { computed } from 'vue'
import { KeyComboImpl } from '@/stores/keybindingStore'
const props = withDefaults(
defineProps<{
keyCombo: KeyComboImpl
isModified: boolean
}>(),
{
isModified: false
}
)
const { keyCombo, isModified = false } = defineProps<{
keyCombo: KeyComboImpl
isModified?: boolean
}>()
const keySequences = computed(() => props.keyCombo.getKeySequences())
const keySequences = computed(() => keyCombo.getKeySequences())
</script>

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

@@ -30,7 +30,6 @@ const widgets = computed(() =>
)
)
const DEFAULT_MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
@@ -49,7 +48,7 @@ const updateWidgets = () => {
widgetState.visible = visible
if (visible) {
const margin = widget.options.margin ?? DEFAULT_MARGIN
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [
(widget.width ?? node.width) - margin * 2,

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'
@@ -68,6 +69,7 @@ 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 +82,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 +161,56 @@ watch(
}
)
// Update the progress of the executing node
watch(
() =>
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
string | null,
number | null
],
([executingNodeId, executingNodeProgress]) => {
if (!executingNodeId) return
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()

View File

@@ -52,8 +52,11 @@ watch(
(canvas: LGraphCanvas | null) => {
if (!canvas) return
canvas.onSelectionChange = useChainCallback(canvas.onSelectionChange, () =>
positionSelectionOverlay(canvas)
canvas.onSelectionChange = useChainCallback(
canvas.onSelectionChange,
// Wait for next frame as sometimes the selected items haven't been
// rendered yet, so the boundingRect is not available on them.
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
)
},
{ immediate: true }
@@ -88,7 +91,12 @@ watch(
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
}, 100)
} else {
visible.value = false
// Selection change update to visible state is delayed by a frame. Here
// we also delay a frame so that the order of events is correct when
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
})
}
}
)

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

@@ -7,7 +7,7 @@
<Load3DScene
ref="load3DSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -30,6 +30,7 @@
@edgeThresholdChange="listenEdgeThresholdChange"
/>
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -42,7 +43,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="false"
:edgeThreshold="edgeThreshold"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@@ -54,12 +54,15 @@
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@uploadTexture="handleUploadTexture"
@exportModel="handleExportModel"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
@@ -72,7 +75,9 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
const { t } = useI18n()
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
@@ -153,6 +158,23 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
node.properties['Background Image'] = backgroundImage.value
}
const handleUploadTexture = async (file: File) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const texturePath = await Load3dUtils.uploadFile(file)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath
} catch (error) {
console.error('Error applying texture:', error)
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
}
}
const handleUpdateFOV = (value: number) => {
fov.value = value
@@ -183,6 +205,24 @@ const handleUpdateMaterialMode = (value: MaterialMode) => {
node.properties['Material Mode'] = value
}
const handleExportModel = async (format: string) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
return
}
try {
await load3DSceneRef.value.load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.toUpperCase()
})
)
}
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode

View File

@@ -7,7 +7,7 @@
<Load3DAnimationScene
ref="load3DAnimationSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -35,6 +35,7 @@
/>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -47,7 +48,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="true"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@toggleGrid="toggleGrid"

View File

@@ -1,7 +1,7 @@
<template>
<Load3DScene
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -29,14 +29,14 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const props = defineProps<{
node: any
type: Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number

View File

@@ -16,14 +16,14 @@
>
<div class="flex flex-col">
<Button
v-for="(label, category) in categories"
v-for="category in availableCategories"
:key="category"
class="p-button-text w-full flex items-center justify-start"
:class="{ 'bg-gray-600': activeCategory === category }"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)"></i>
<span class="text-white">{{ t(label) }}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -43,13 +43,14 @@
<ModelControls
v-if="activeCategory === 'model'"
:inputSpec="inputSpec"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="isAnimation"
:edgeThreshold="edgeThreshold"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@uploadTexture="handleUploadTexture"
ref="modelControlsRef"
/>
@@ -70,6 +71,12 @@
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
<ExportControls
v-if="activeCategory === 'export'"
@exportModel="handleExportModel"
ref="exportControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -89,9 +96,10 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -101,10 +109,12 @@ import {
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
showPreview: boolean
@@ -117,24 +127,34 @@ const props = defineProps<{
hasBackgroundImage?: boolean
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<'scene' | 'model' | 'camera' | 'light'>('scene')
const categories = {
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
model: 'load3d.model',
camera: 'load3d.camera',
light: 'load3d.light'
light: 'load3d.light',
export: 'load3d.export'
}
const availableCategories = computed(() => {
const baseCategories = ['scene', 'model', 'camera', 'light']
if (!props.inputSpec.isAnimation) {
return [...baseCategories, 'export']
}
return baseCategories
})
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const selectCategory = (category: 'scene' | 'model' | 'camera' | 'light') => {
const selectCategory = (category: string) => {
activeCategory.value = category
isMenuOpen.value = false
}
@@ -144,7 +164,8 @@ const getCategoryIcon = (category: string) => {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun'
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
@@ -161,6 +182,8 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
(e: 'uploadTexture', file: File): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -209,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
@@ -217,6 +244,10 @@ const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -13,17 +13,16 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
Load3DNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLoad3dService } from '@/services/load3dService'
const props = defineProps<{
node: LGraphNode
type: Load3DNodeType | Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
@@ -60,7 +59,16 @@ const eventConfig = {
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading()
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
exportLoadingStart: (message: string) => {
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
},
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
textureLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading()
} as const
watchEffect(() => {
@@ -135,7 +143,7 @@ onMounted(() => {
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.type
props.inputSpec
)
handleEvents('add')
})

View File

@@ -15,19 +15,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = (message?: string) => {
modelLoading.value = true
const startLoading = async (message?: string) => {
loadingMessage.value = message || t('load3d.loadingModel')
modelLoading.value = true
await nextTick()
}
const endLoading = () => {
const endLoading = async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
modelLoading.value = false
}

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex flex-col">
<div class="relative show-export-formats">
<Button
class="p-button-rounded p-button-text"
@click="toggleExportFormats"
>
<i
class="pi pi-download text-white text-lg"
v-tooltip.right="{
value: t('load3d.exportModel'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showExportFormats"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
class="p-button-text text-white"
@click="exportModel(format.value)"
>
{{ format.label }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
]
const toggleExportFormats = () => {
showExportFormats.value = !showExportFormats.value
}
const exportModel = (format: string) => {
emit('exportModel', format)
showExportFormats.value = false
}
const closeExportFormatsList = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-export-formats')) {
showExportFormats.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeExportFormatsList)
})
onUnmounted(() => {
document.removeEventListener('click', closeExportFormatsList)
})
</script>

View File

@@ -58,6 +58,33 @@
</div>
</div>
</div>
<div
v-if="
materialMode === 'original' &&
!props.inputSpec.isAnimation &&
!props.inputSpec.isPreview
"
class="relative show-texture-upload"
>
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadTexture'),
showDelay: 300
}"
></i>
<input
type="file"
ref="texturePickerRef"
accept="image/*"
@change="uploadTexture"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@@ -100,13 +127,14 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
@@ -114,6 +142,7 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'uploadTexture', file: File): void
}>()
const upDirection = ref(props.upDirection || 'original')
@@ -122,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const texturePickerRef = ref<HTMLInputElement | null>(null)
const upDirections: UpDirection[] = [
'original',
@@ -141,7 +171,7 @@ const materialModes = computed(() => {
//'depth' disable for now
]
if (!props.isAnimation) {
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
modes.push('lineart')
}
@@ -217,6 +247,18 @@ const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const openTextureUpload = () => {
texturePickerRef.value?.click()
}
const uploadTexture = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('uploadTexture', input.files[0])
}
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -35,7 +35,7 @@
</Dialog>
<AutoCompletePlus
:model-value="props.filters"
:model-value="filters"
class="comfy-vue-node-search-box z-10 flex-grow"
scrollHeight="40vh"
:placeholder="placeholder"
@@ -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'
@@ -99,15 +104,10 @@ const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
)
const props = withDefaults(
defineProps<{
filters: FilterAndValue[]
searchLimit?: number
}>(),
{
searchLimit: 64
}
)
const { filters, searchLimit = 64 } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
searchLimit?: number
}>()
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
@@ -115,19 +115,19 @@ const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const placeholder = computed(() => {
return props.filters.length === 0 ? t('g.searchNodes') + '...' : ''
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const search = (query: string) => {
const queryIsEmpty = query === '' && props.filters.length === 0
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
...nodeDefStore.nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
...nodeDefStore.nodeSearchService.searchNode(query, filters, {
limit: searchLimit
})
]
}
@@ -144,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

@@ -46,7 +46,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { TreeNode } from 'primevue/treenode'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -64,6 +63,7 @@ import {
} from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { isElectron } from '@/utils/envUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -116,13 +116,11 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
return {
key: node.key,
// @ts-expect-error fixme ts strict error
label: model
? nameFormat === 'title'
? model.title
: model.simplified_file_name
: node.label,
// @ts-expect-error fixme ts strict error
leaf: node.leaf,
data: node.data,
getIcon() {
@@ -136,14 +134,13 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
}
return 'pi pi-folder'
},
// @ts-expect-error fixme ts strict error
getBadgeText() {
// Return null to apply default badge text
// Return undefined to apply default badge text
// Return empty string to hide badge
if (!folder) {
return null
return
}
return folder.state === ResourceState.Loaded ? null : ''
return folder.state === ResourceState.Loaded ? undefined : ''
},
children,
draggable: node.leaf,

View File

@@ -65,7 +65,6 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { TreeNode } from 'primevue/treenode'
import { Ref, computed, h, nextTick, ref, render } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -77,14 +76,15 @@ 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,
buildNodeDefTree,
useNodeDefStore
} 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'
@@ -114,10 +114,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
return {
key: node.key,
label: node.leaf ? node.data.display_name : node.label,
// @ts-expect-error fixme ts strict error
leaf: node.leaf,
data: node.data,
// @ts-expect-error fixme ts strict error
getIcon() {
if (this.leaf) {
return 'pi pi-circle-fill'
@@ -152,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.
@@ -163,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,
@@ -181,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

@@ -125,7 +125,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import type { TreeNode } from 'primevue/treenode'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +144,7 @@ import {
} from '@/stores/workflowStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
@@ -267,9 +267,7 @@ const renderTreeNode = (
return {
key: node.key,
// @ts-expect-error fixme ts strict error
label: node.label,
// @ts-expect-error fixme ts strict error
leaf: node.leaf,
data: node.data,
children,

View File

@@ -22,7 +22,6 @@
</template>
<script setup lang="ts">
import type { TreeNode } from 'primevue/treenode'
import { computed, h, nextTick, ref, render, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,6 +34,7 @@ import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type {
RenderedTreeExplorerNode,
TreeExplorerDragAndDropData,
@@ -131,7 +131,6 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
return {
key: node.key,
label: node.leaf ? node.data.display_name : node.label,
// @ts-expect-error fixme ts strict error
leaf: node.leaf,
data: node.data,
getIcon() {

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

@@ -7,8 +7,8 @@
option-group-label="label"
option-label="title"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
root: { class: 'w-full border-0 bg-transparent' },
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }

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

@@ -9,8 +9,8 @@ export const useChainCallback = <T extends (...args: any[]) => void>(
originalCallback: T | undefined,
...callbacks: ((...args: Parameters<T>) => void)[]
) => {
return (...args: Parameters<T>) => {
originalCallback?.(...args)
callbacks.forEach((callback) => callback(...args))
return function (this: unknown, ...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, cancelRequests } = 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 = (ids: Parameters<typeof getPackById>[0]) =>
ids ? getPackById(ids) : 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 = () => {
cancelRequests()
clear()
getPacksByIds.cancel()
isReady.value = false
isLoading.value = false
}
return {

View File

@@ -1,3 +1,4 @@
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -55,14 +56,14 @@ export function useTreeFolderOperations(
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
) => {
): MenuItem => {
return {
label: t('g.newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
if (targetNode) addFolderCommand(targetNode)
},
visible: targetNode && !targetNode.leaf && !!targetNode.handleAddFolder,
visible: !!targetNode && !targetNode.leaf && !!targetNode.handleAddFolder,
isAsync: false
}
}

View File

@@ -184,8 +184,8 @@ export function useCoreCommands(): ComfyCommand[] {
await api.interrupt()
useToastStore().add({
severity: 'info',
summary: 'Interrupted',
detail: 'Execution has been interrupted',
summary: t('g.interrupted'),
detail: t('toastMessages.interrupted'),
life: 1000
})
}
@@ -198,8 +198,8 @@ export function useCoreCommands(): ComfyCommand[] {
await useQueueStore().clear(['queue'])
useToastStore().add({
severity: 'info',
summary: 'Confirmed',
detail: 'Pending tasks deleted',
summary: t('g.confirmed'),
detail: t('toastMessages.pendingTasksDeleted'),
life: 3000
})
}
@@ -246,7 +246,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (app.canvas.empty) {
useToastStore().add({
severity: 'error',
summary: 'Empty canvas',
summary: t('toastMessages.emptyCanvas'),
life: 3000
})
return
@@ -325,9 +325,8 @@ export function useCoreCommands(): ComfyCommand[] {
if (!canvas.selectedItems?.size) {
useToastStore().add({
severity: 'error',
summary: 'Nothing to group',
detail:
'Please select the nodes (or other groups) to create a group for',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup'),
life: 3000
})
return

View File

@@ -1,6 +1,7 @@
import type { TreeNode } from 'primevue/treenode'
import { Ref } from 'vue'
import type { TreeNode } from '@/types/treeExplorerTypes'
export function useTreeExpansion(expandedKeys: Ref<Record<string, boolean>>) {
const toggleNode = (node: TreeNode) => {
if (node.key && typeof node.key === 'string') {

View File

@@ -8,6 +8,7 @@ import type {
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
@@ -36,7 +37,9 @@ const renderPreview = (
node.imageIndex = imageIndex = 0
}
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = 15
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
@@ -165,12 +168,14 @@ const renderPreview = (
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,

View File

@@ -219,6 +219,13 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Node.AllowImageSizeDraw',
category: ['LiteGraph', 'Node Widget', 'AllowImageSizeDraw'],
name: 'Show width × height below the image preview',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Group.DoubleClickTitleToEdit',
category: ['LiteGraph', 'Group', 'DoubleClickTitleToEdit'],
@@ -272,7 +279,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' }
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
@@ -524,17 +532,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.3.42'
},
{
id: 'Comfy.RerouteBeta',
category: ['LiteGraph', 'RerouteBeta'],
name: 'Opt-in to the reroute beta test',
tooltip: 'No longer has any effect; reroutes are always enabled.',
deprecated: true,
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.42',
versionModified: '1.13.3'
},
{
id: 'Comfy.Graph.LinkMarkers',
category: ['LiteGraph', 'Link', 'LinkMarkers'],

View File

@@ -927,7 +927,6 @@ export class GroupNodeHandler {
// @ts-expect-error fixme ts strict error
const nodes = this.node.convertToNodes()
// @ts-expect-error fixme ts strict error
const groupNode = LiteGraph.createNode(this.node.type)
// @ts-expect-error fixme ts strict error
groupNode.id = id
@@ -1145,7 +1144,6 @@ export class GroupNodeHandler {
},
{
content: 'Manage Group Node',
// @ts-expect-error fixme ts strict error
callback: () => manageGroupNodes(this.type)
}
)

View File

@@ -12,6 +12,7 @@ import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'
import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'

View File

@@ -7,6 +7,7 @@ import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
@@ -32,11 +33,13 @@ useExtensionService().registerExtension({
(w: IWidget) => w.name === 'model_file'
) as IStringWidget
node.properties['Texture'] = undefined
const uploadPath = await Load3dUtils.uploadFile(
fileInput.files[0]
).catch((error) => {
console.error('File upload failed:', error)
useToastStore().addAlert('File upload failed')
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
})
const modelUrl = api.apiURL(
@@ -70,12 +73,16 @@ useExtensionService().registerExtension({
)
if (modelWidget) {
modelWidget.value = ''
node.properties['Texture'] = undefined
}
})
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Load3D'
type: 'Load3D',
isAnimation: false,
isPreview: false
}
const widget = new ComponentWidgetImpl({
@@ -117,7 +124,7 @@ useExtensionService().registerExtension({
const height = node.widgets?.find((w: IWidget) => w.name === 'height')
const sceneWidget = node.widgets?.find((w: IWidget) => w.name === 'image')
if (modelWidget && width && height && cameraState && sceneWidget) {
if (modelWidget && width && height && sceneWidget) {
config.configure('input', modelWidget, cameraState, width, height)
sceneWidget.serializeValue = async () => {
@@ -174,7 +181,7 @@ useExtensionService().registerExtension({
fileInput.files[0]
).catch((error) => {
console.error('File upload failed:', error)
useToastStore().addAlert('File upload failed')
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
})
const modelUrl = api.apiURL(
@@ -213,7 +220,9 @@ useExtensionService().registerExtension({
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Load3DAnimation'
type: 'Load3DAnimation',
isAnimation: true,
isPreview: false
}
const widget = new ComponentWidgetImpl({
@@ -254,7 +263,7 @@ useExtensionService().registerExtension({
const width = node.widgets?.find((w: IWidget) => w.name === 'width')
const height = node.widgets?.find((w: IWidget) => w.name === 'height')
if (modelWidget && width && height && cameraState && sceneWidget) {
if (modelWidget && width && height && sceneWidget) {
const config = new Load3DConfiguration(load3d)
config.configure('input', modelWidget, cameraState, width, height)
@@ -306,7 +315,9 @@ useExtensionService().registerExtension({
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D'
type: 'Preview3D',
isAnimation: false,
isPreview: true
}
const widget = new ComponentWidgetImpl({
@@ -342,10 +353,8 @@ useExtensionService().registerExtension({
let filePath = message.model_file[0]
if (!filePath) {
const msg = 'unable to get model file path.'
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
@@ -381,7 +390,9 @@ useExtensionService().registerExtension({
PREVIEW_3D_ANIMATION(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3DAnimation'
type: 'Preview3DAnimation',
isAnimation: true,
isPreview: true
}
const widget = new ComponentWidgetImpl({
@@ -417,10 +428,8 @@ useExtensionService().registerExtension({
let filePath = message.model_file[0]
if (!filePath) {
const msg = 'unable to get model file path.'
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}

View File

@@ -7,6 +7,11 @@ import { api } from '@/scripts/api'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
this.setupDefaultProperties()
}
configure(
loadFolder: 'input' | 'output',
modelWidget: IWidget,
@@ -33,6 +38,17 @@ class Load3DConfiguration {
}
}
private setupModelHandlingForSaveMesh(
filePath: string,
loadFolder: 'input' | 'output'
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
if (filePath) {
onModelWidgetUpdate(filePath)
}
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',
@@ -45,7 +61,12 @@ class Load3DConfiguration {
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
}
modelWidget.callback = onModelWidgetUpdate
modelWidget.callback = (value: string | number | boolean | object) => {
this.load3d.node.properties['Texture'] = undefined
onModelWidgetUpdate(value)
}
}
private setupDefaultProperties() {
@@ -120,6 +141,12 @@ class Load3DConfiguration {
this.load3d.setEdgeThreshold(edgeThreshold)
const texturePath = this.load3d.loadNodeProperty('Texture', null)
if (texturePath) {
await this.load3d.applyTexture(texturePath)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -1,11 +1,14 @@
import { LGraphNode } from '@comfyorg/litegraph'
import * as THREE from 'three'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { ModelManager } from './ModelManager'
import { NodeStorage } from './NodeStorage'
import { PreviewManager } from './PreviewManager'
@@ -23,7 +26,7 @@ class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
protected animationFrameId: number | null = null
protected node: LGraphNode
node: LGraphNode
protected eventManager: EventManager
protected nodeStorage: NodeStorage
@@ -43,7 +46,8 @@ class Load3d {
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
node: {} as LGraphNode
node: {} as LGraphNode,
inputSpec: {} as CustomInputSpec
}
) {
this.node = options.node || ({} as LGraphNode)
@@ -108,7 +112,8 @@ class Load3d {
this.renderer,
this.eventManager,
this.getActiveCamera.bind(this),
this.setupCamera.bind(this)
this.setupCamera.bind(this),
options
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
@@ -123,7 +128,7 @@ class Load3d {
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
if (options && options.createPreview) {
if (options && !options.inputSpec?.isPreview) {
this.previewManager.createCapturePreview(container)
this.previewManager.init()
}
@@ -221,6 +226,64 @@ class Load3d {
)
}
async exportModel(format: string): Promise<void> {
if (!this.modelManager.currentModel) {
throw new Error('No model to export')
}
const exportMessage = `Exporting as ${format.toUpperCase()}...`
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
try {
const model = this.modelManager.currentModel.clone()
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
await new Promise((resolve) => setTimeout(resolve, 10))
switch (format) {
case 'glb':
await ModelExporter.exportGLB(model, filename, originalURL)
break
case 'obj':
await ModelExporter.exportOBJ(model, filename, originalURL)
break
case 'stl':
await ModelExporter.exportSTL(model, filename), originalURL
break
default:
throw new Error(`Unsupported export format: ${format}`)
}
await new Promise((resolve) => setTimeout(resolve, 10))
} catch (error) {
console.error(`Error exporting model as ${format}:`, error)
throw error
} finally {
this.eventManager.emitEvent('exportLoadingEnd', null)
}
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.modelManager.currentModel) {
throw new Error('No model to apply texture to')
}
this.eventManager.emitEvent('textureLoadingStart', null)
try {
await this.modelManager.applyTexture(texturePath)
} catch (error) {
console.error('Error applying texture:', error)
throw error
} finally {
this.eventManager.emitEvent('textureLoadingEnd', null)
}
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.forceRender()

View File

@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useToastStore } from '@/stores/toastStore'
@@ -55,7 +56,9 @@ class Load3dUtils {
} catch (error) {
console.error('Upload error:', error)
useToastStore().addAlert(
error instanceof Error ? error.message : 'Upload failed'
error instanceof Error
? error.message
: t('toastMessages.fileUploadFailed')
)
}

View File

@@ -5,6 +5,7 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { t } from '@/i18n'
import { useToastStore } from '@/stores/toastStore'
import {
@@ -47,16 +48,27 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.modelManager.originalURL = url
let fileExtension: string | undefined
if (originalFileName) {
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
this.modelManager.originalFileName =
originalFileName.split('/').pop()?.split('.')[0] || 'model'
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
if (filename) {
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
} else {
this.modelManager.originalFileName = 'model'
}
}
if (!fileExtension) {
useToastStore().addAlert('Could not determine file type')
useToastStore().addAlert(t('toastMessages.couldNotDetermineFileType'))
return
}
@@ -70,7 +82,7 @@ export class LoaderManager implements LoaderManagerInterface {
} catch (error) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert('Error loading model')
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}

View File

@@ -0,0 +1,170 @@
import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
import { t } from '@/i18n'
import { useToastStore } from '@/stores/toastStore'
export class ModelExporter {
static detectFormatFromURL(url: string): string | null {
try {
const filenameParam = new URLSearchParams(url.split('?')[1]).get(
'filename'
)
if (filenameParam) {
const extension = filenameParam.split('.').pop()?.toLowerCase()
return extension || null
}
} catch (e) {
console.error('Error parsing URL:', e)
}
return null
}
static canUseDirectURL(url: string | null, format: string): boolean {
if (!url) return false
const urlFormat = ModelExporter.detectFormatFromURL(url)
if (!urlFormat) return false
return urlFormat.toLowerCase() === format.toLowerCase()
}
static async downloadFromURL(
url: string,
desiredFilename: string
): Promise<void> {
try {
const response = await fetch(url)
const blob = await response.blob()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = desiredFilename
link.click()
URL.revokeObjectURL(link.href)
} catch (error) {
console.error('Error downloading from URL:', error)
useToastStore().addAlert(t('toastMessages.failedToDownloadFile'))
throw error
}
}
static async exportGLB(
model: THREE.Object3D,
filename: string = 'model.glb',
originalURL?: string | null
): Promise<void> {
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'glb')) {
console.log('Using direct URL download for GLB')
return ModelExporter.downloadFromURL(originalURL, filename)
}
const exporter = new GLTFExporter()
try {
await new Promise((resolve) => setTimeout(resolve, 50))
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
model,
(gltf) => {
resolve(gltf as ArrayBuffer)
},
(error) => {
reject(error)
},
{ binary: true }
)
})
await new Promise((resolve) => setTimeout(resolve, 50))
ModelExporter.saveArrayBuffer(result, filename)
} catch (error) {
console.error('Error exporting GLB:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', { format: 'GLB' })
)
throw error
}
}
static async exportOBJ(
model: THREE.Object3D,
filename: string = 'model.obj',
originalURL?: string | null
): Promise<void> {
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'obj')) {
console.log('Using direct URL download for OBJ')
return ModelExporter.downloadFromURL(originalURL, filename)
}
const exporter = new OBJExporter()
try {
await new Promise((resolve) => setTimeout(resolve, 50))
const result = exporter.parse(model)
await new Promise((resolve) => setTimeout(resolve, 50))
ModelExporter.saveString(result, filename)
} catch (error) {
console.error('Error exporting OBJ:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', { format: 'OBJ' })
)
throw error
}
}
static async exportSTL(
model: THREE.Object3D,
filename: string = 'model.stl',
originalURL?: string | null
): Promise<void> {
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'stl')) {
console.log('Using direct URL download for STL')
return ModelExporter.downloadFromURL(originalURL, filename)
}
const exporter = new STLExporter()
try {
await new Promise((resolve) => setTimeout(resolve, 50))
const result = exporter.parse(model)
await new Promise((resolve) => setTimeout(resolve, 50))
ModelExporter.saveString(result, filename)
} catch (error) {
console.error('Error exporting STL:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', { format: 'STL' })
)
throw error
}
}
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
const blob = new Blob([buffer], { type: 'application/octet-stream' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
private static saveString(text: string, filename: string): void {
const blob = new Blob([text], { type: 'text/plain' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
}

View File

@@ -5,6 +5,7 @@ import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeome
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import Load3dUtils from './Load3dUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
@@ -12,6 +13,7 @@ import { ConditionalLineMaterial } from './conditional-lines/Lines2/ConditionalL
import { ConditionalLineSegmentsGeometry } from './conditional-lines/Lines2/ConditionalLineSegmentsGeometry'
import {
EventManagerInterface,
Load3DOptions,
MaterialMode,
ModelManagerInterface,
UpDirection
@@ -34,6 +36,10 @@ export class ModelManager implements ModelManagerInterface {
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
originalFileName: string | null = null
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
textureLoader: THREE.TextureLoader
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
@@ -41,6 +47,7 @@ export class ModelManager implements ModelManagerInterface {
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3) => void
private lineartModel: THREE.Group
private createLineartModel: boolean = false
LIGHT_MODEL = 0xffffff
LIGHT_LINES = 0x455a64
@@ -56,13 +63,23 @@ export class ModelManager implements ModelManagerInterface {
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3) => void
setupCamera: (size: THREE.Vector3) => void,
options: Load3DOptions
) {
this.scene = scene
this.renderer = renderer
this.eventManager = eventManager
this.activeCamera = getActiveCamera()
this.setupCamera = setupCamera
this.textureLoader = new THREE.TextureLoader()
if (
options &&
!options.inputSpec?.isPreview &&
!options.inputSpec?.isAnimation
) {
this.createLineartModel = true
}
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
@@ -100,6 +117,11 @@ export class ModelManager implements ModelManagerInterface {
this.wireframeMaterial.dispose()
this.depthMaterial.dispose()
if (this.appliedTexture) {
this.appliedTexture.dispose()
this.appliedTexture = null
}
this.disposeLineartModel()
}
@@ -113,6 +135,66 @@ export class ModelManager implements ModelManagerInterface {
})
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.currentModel) {
throw new Error('No model available to apply texture to')
}
if (this.appliedTexture) {
this.appliedTexture.dispose()
}
try {
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(texturePath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
}
this.appliedTexture = await new Promise<THREE.Texture>(
(resolve, reject) => {
this.textureLoader.load(
imageUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
resolve(texture)
},
undefined,
(error) => reject(error)
)
}
)
if (this.materialMode === 'original') {
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
const material = new THREE.MeshStandardMaterial({
map: this.appliedTexture,
metalness: 0.1,
roughness: 0.8,
side: THREE.DoubleSide
})
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = material
}
})
}
return Promise.resolve()
} catch (error) {
console.error('Error applying texture:', error)
return Promise.reject(error)
}
}
disposeLineartModel(): void {
this.disposeEdgesModel()
this.disposeShadowModel()
@@ -563,7 +645,16 @@ export class ModelManager implements ModelManagerInterface {
if (originalMaterial) {
child.material = originalMaterial
} else {
child.material = this.standardMaterial
if (this.appliedTexture) {
child.material = new THREE.MeshStandardMaterial({
map: this.appliedTexture,
metalness: 0.1,
roughness: 0.8,
side: THREE.DoubleSide
})
} else {
child.material = this.standardMaterial
}
}
break
}
@@ -622,6 +713,13 @@ export class ModelManager implements ModelManagerInterface {
this.originalRotation = null
this.currentUpDirection = 'original'
this.setMaterialMode('original')
this.originalFileName = null
this.originalURL = null
if (this.appliedTexture) {
this.appliedTexture.dispose()
this.appliedTexture = null
}
this.originalMaterials = new WeakMap()
}
@@ -657,7 +755,9 @@ export class ModelManager implements ModelManagerInterface {
this.setupCamera(size)
this.setupLineartModel()
if (this.createLineartModel) {
this.setupLineartModel()
}
}
setupLineartModel(): void {

View File

@@ -8,6 +8,8 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type Load3DNodeType = 'Load3D' | 'Preview3D'
export type Load3DAnimationNodeType = 'Load3DAnimation' | 'Preview3DAnimation'
@@ -33,8 +35,8 @@ export interface EventCallback {
}
export interface Load3DOptions {
createPreview?: boolean
node?: LGraphNode
inputSpec?: CustomInputSpec
}
export interface CaptureResult {
@@ -140,6 +142,8 @@ export interface AnimationManagerInterface extends BaseManager {
}
export interface ModelManagerInterface {
originalFileName: string | null
originalURL: string | null
currentModel: THREE.Object3D | null
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null
originalRotation: THREE.Euler | null

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

@@ -140,7 +140,7 @@ class ManageTemplates extends ComfyDialog {
exportAll() {
if (this.templates.length == 0) {
useToastStore().addAlert('No templates to export.')
useToastStore().addAlert(t('toastMessages.noTemplatesToExport'))
return
}

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,79 @@
import { IWidget } from '@comfyorg/litegraph'
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.SaveGLB',
async beforeRegisterNodeDef(_nodeType, nodeData) {
if ('SaveGLB' === nodeData.name) {
// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.image = ['PREVIEW_3D']
}
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isAnimation: false,
isPreview: true
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,
inputSpec,
options: {}
})
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'SaveGLB') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const fileInfo = message['3d'][0]
const load3d = useLoad3dService().getLoad3d(node)
const modelWidget = node.widgets?.find((w: IWidget) => w.name === 'image')
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d)
config.configureForSaveMesh(fileInfo['type'], filePath)
}
}
}
})

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