Compare commits

...

56 Commits

Author SHA1 Message Date
Terry Jia
d4fd64ef38 don't merge, test for PrimeVue Select 2025-03-12 19:48:17 -04:00
Chenlei Hu
e1e8fcf62c [TS] Type various WidgetOption props (#3010) 2025-03-12 14:44:50 -04:00
Christian Byrne
d133aeb614 Add Comfy Manager store (#3007) 2025-03-12 12:44:57 -04:00
Christian Byrne
e371880a4e [Style] Remove unused classes (#3005) 2025-03-12 09:36:44 -07:00
Chenlei Hu
bcd76ba49b Vue component multi-select widget (#2987) 2025-03-12 12:03:21 -04:00
dependabot[bot]
aad7ded636 Bump @babel/runtime from 7.24.7 to 7.26.10 (#3002)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 11:00:25 -04:00
Christian Byrne
3e8ef33cbc Fetch model metadata for Civitai models embedded in workflows (#2994) 2025-03-12 10:43:15 -04:00
Christian Byrne
0facb0458b Whitelist RealESRGAN_x4Plus model (#2995) 2025-03-12 10:42:19 -04:00
Christian Byrne
fd6aae141a Rename 'ControlNet' template to 'Scribble ControlNet' (#3000) 2025-03-12 10:42:04 -04:00
Christian Byrne
9be090f929 [Style] Improve error message in missing models dialog (#2997) 2025-03-12 10:41:31 -04:00
Chenlei Hu
313f32b094 Wrap widget value in API format workflow (#2989) 2025-03-12 10:00:05 -04:00
Comfy Org PR Bot
d76391b682 1.13.3 (#2990)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-11 21:44:29 -04:00
Chenlei Hu
3c68a1da2c [CI] Directly publish release for main branch release (#2991) 2025-03-11 21:44:14 -04:00
Christian Byrne
3de1ef993e Fix image not shown on PreviewImage (#2988) 2025-03-11 19:30:16 -04:00
Comfy Org PR Bot
29df14f477 [chore] Update litegraph to 0.10.2 (#2984)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-12 04:14:55 +11:00
Comfy Org PR Bot
e4e7d53fcc [chore] Update litegraph to 0.10.1 (#2983)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-12 03:48:42 +11:00
filtered
8fa970ffba Standardise widgets - always initialize y to 0 (#2982) 2025-03-11 11:31:03 -04:00
Chenlei Hu
d4c3685326 [TS] Type Clipspace.widgets (#2981) 2025-03-11 10:49:50 -04:00
Chenlei Hu
ebb030c401 [Bug] Fix mask editor saving (#2979) 2025-03-11 10:13:18 -04:00
Chenlei Hu
db6a25a092 [Bug] Fix overwriting of hideOnZoom widget option (#2978) 2025-03-11 09:57:57 -04:00
Christian Byrne
a90b6519b5 [Style] Remove underline hover effect in custom nodes manager (#2977) 2025-03-11 09:49:20 -04:00
Christian Byrne
c2006412de Fix custom nodes license parsing logic (#2975) 2025-03-11 09:49:09 -04:00
Christian Byrne
a046e00bc3 Add node pack version selector dropdown (#2973)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-11 09:48:25 -04:00
Christian Byrne
b347693f4d Fix manager info panel metadata text (#2971) 2025-03-11 09:47:47 -04:00
Christian Byrne
c4ca694d1d Add composable to manage ComfyUI-Manager task queue (#2970) 2025-03-11 09:47:29 -04:00
Christian Byrne
cd7e2d7b91 Add Comfy Manager service (#2968) 2025-03-11 09:46:21 -04:00
Christian Byrne
46029a5ce3 Add ComfyUI-Manager API types (#2967) 2025-03-11 09:45:43 -04:00
Christian Byrne
4123cb611c [Style] Fix registry search filter dropdown style (#2974) 2025-03-11 06:24:56 -07:00
filtered
03bf81cc1b [Cleanup] Remove redundant reroute opt-in setting (#2972)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-11 23:25:16 +11:00
Comfy Org PR Bot
710bc2bb2e 1.13.2 (#2965)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-10 21:54:08 -04:00
Comfy Org PR Bot
e6ef4c8e6d [chore] Update litegraph to 0.10.0 (#2963)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-03-10 15:06:01 -04:00
Chenlei Hu
62528fde2e [Refactor] Move DOM widget event listeners to vue (#2960) 2025-03-10 13:07:23 -04:00
Chenlei Hu
abe4754904 Use relative url path for reverse proxy hosting on template thumbnails (#2959) 2025-03-10 10:13:41 -04:00
Christian Byrne
3eb036b5e3 Fix dark mode card surface in workflow templates dialog (#2942) 2025-03-10 09:57:19 -04:00
Tristan Sommer
e29a5d3047 Maskeditor: Added opacity controls for the color select tool and the paint bucket tool (#2955) 2025-03-10 14:05:21 +01:00
Chenlei Hu
9cea1fe0d3 Only update widget state when visible (#2954) 2025-03-09 23:27:48 -04:00
Comfy Org PR Bot
61cf07be88 1.13.1 (#2953)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-09 22:02:05 -04:00
Chenlei Hu
ec013cc511 Draw canvas image preview in a widget (#2952) 2025-03-09 21:56:50 -04:00
dependabot[bot]
60022134ed Bump vue-i18n from 9.13.1 to 9.14.3 (#2951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 20:25:41 -04:00
Chenlei Hu
1558a5bb19 [Refactor] Add useNodeAnimatedImage composable (#2950) 2025-03-09 18:08:03 -04:00
Terry Jia
445be93d50 [3d] extract controls classes (#2948) 2025-03-09 17:32:49 -04:00
dependabot[bot]
4b05d5a8fa Bump axios from 1.7.4 to 1.8.2 (#2949)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 17:32:40 -04:00
Chenlei Hu
131229f02f Use uuid for dom widget id (#2947) 2025-03-09 17:22:18 -04:00
Chenlei Hu
3a0b337d0c Manage style of DOM widgets in Vue (#2946) 2025-03-09 16:51:42 -04:00
Christian Byrne
97d9f90374 [Manager] Use iI8n for date strings (#2938) 2025-03-09 13:48:53 -04:00
Christian Byrne
496abfde53 Fix settings content not visible on large font sizes (#2940) 2025-03-09 13:48:27 -04:00
Christian Byrne
72994621a6 Translate template categories (#2937)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-09 13:47:49 -04:00
Christian Byrne
83f9240587 [Manager] Show progress spinner on initial load (#2936) 2025-03-09 13:47:14 -04:00
filtered
ba8c1ee823 Remove legacy code (follow-up on #2925) (#2930) 2025-03-09 13:44:55 -04:00
Terry Jia
3d6fe41ee9 [3d] add edge threshold support (#2939)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-09 13:44:30 -04:00
Comfy Org PR Bot
96b84761f3 1.13.0 (#2929)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-08 21:29:34 -05:00
Comfy Org PR Bot
d32ff0b018 [chore] Update litegraph to 0.10.0-0 (#2928)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-08 21:25:26 -05:00
Chenlei Hu
6e378c68f9 [Bug] Fix load audio node (#2927)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-08 20:17:19 -05:00
Chenlei Hu
65573f106b [Cleanup] Use WidgetOptions.step2 (#2926) 2025-03-08 19:10:33 -05:00
Christian Byrne
d8721760f1 Add custom nodes manager UI (#2923)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-08 18:54:08 -05:00
filtered
f53c04834f Add support for Litegraph LinkConnector (#2925) 2025-03-09 09:37:47 +11:00
105 changed files with 5940 additions and 1045 deletions

View File

@@ -61,7 +61,7 @@ jobs:
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' }}
draft: true
draft: ${{ github.event.pull_request.base.ref != 'main' }}
prerelease: false
generate_release_notes: true

View File

@@ -10,7 +10,7 @@ module.exports = defineConfig({
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade.
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,32 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadAudio",
"pos": [41.5296516418457, 16.930862426757812],
"size": [315, 82],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": [null, ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -134,3 +134,10 @@ test.describe('Load image widget', () => {
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
})
})
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

67
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.12.8",
"version": "1.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.12.8",
"version": "1.13.3",
"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.20",
"@comfyorg/litegraph": "^0.9.9",
"@comfyorg/litegraph": "^0.10.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -27,7 +27,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
@@ -39,7 +39,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
@@ -223,9 +223,10 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz",
"integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -297,9 +298,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.9.9.tgz",
"integrity": "sha512-OOvUXLx1Zq71ps1pfmPO4rz3GAGNGNc/6BWvHJl0fUvbkpofnCZQKUuNIrPL8LMGiQPzkRGBbyWcy3uQ9dldhg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.2.tgz",
"integrity": "sha512-+7P+SeEAEervFOe0RSmWZGB+WamUH0zgOLHjypWpBqxQNEf4Z2JL8dLUOi8IIw+Le/PT38bD2bp/7KAggsI9WA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -953,12 +954,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.13.1.tgz",
"integrity": "sha512-+bcQRkJO9pcX8d0gel9ZNfrzU22sZFSA0WVhfXrf5jdJOS24a+Bp8pozuS9sBI9Hk/tGz83pgKfmqcn/Ci7/8w==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.3.tgz",
"integrity": "sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.13.1",
"@intlify/shared": "9.13.1"
"@intlify/message-compiler": "9.14.3",
"@intlify/shared": "9.14.3"
},
"engines": {
"node": ">= 16"
@@ -968,11 +970,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.13.1.tgz",
"integrity": "sha512-SKsVa4ajYGBVm7sHMXd5qX70O2XXjm55zdZB3VeMFCvQyvLew/dLvq3MqnaIsTMF1VkkOb9Ttr6tHcMlyPDL9w==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.3.tgz",
"integrity": "sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.13.1",
"@intlify/shared": "9.14.3",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -983,9 +986,10 @@
}
},
"node_modules/@intlify/shared": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.13.1.tgz",
"integrity": "sha512-u3b6BKGhE6j/JeRU6C/RL2FgyJfy6LakbtfeVF8fJXURpZZTzfh3e05J0bu0XPw447Q6/WUp3C4ajv4TMS4YsQ==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.3.tgz",
"integrity": "sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@@ -4051,9 +4055,9 @@
}
},
"node_modules/axios": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -13404,12 +13408,13 @@
}
},
"node_modules/vue-i18n": {
"version": "9.13.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.13.1.tgz",
"integrity": "sha512-mh0GIxx0wPtPlcB1q4k277y0iKgo25xmDPWioVVYanjPufDBpvu5ySTjP5wOrSvlYQ2m1xI+CFhGdauv/61uQg==",
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.3.tgz",
"integrity": "sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.13.1",
"@intlify/shared": "9.13.1",
"@intlify/core-base": "9.14.3",
"@intlify/shared": "9.14.3",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.12.8",
"version": "1.13.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -73,7 +73,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.9.9",
"@comfyorg/litegraph": "^0.10.2",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -88,7 +88,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
@@ -100,7 +100,7 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,31 @@
<template>
<hr
:class="{
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
}"
:style="{
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
}"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const colorPaletteStore = useColorPaletteStore()
const { orientation = 'horizontal', width = 0.3 } = defineProps<{
orientation?: 'horizontal' | 'vertical'
width?: number
}>()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>

View File

@@ -1,17 +1,27 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="h-min my-2 px-1 max-w-xs"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</div>
</Message>
</div>
<div class="file-action">
<div>
<Button
class="file-action-button"
:label="$t('g.download') + ' (' + fileSize + ')'"
size="small"
outlined
@@ -25,6 +35,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useDownload } from '@/composables/useDownload'

View File

@@ -52,7 +52,8 @@ const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true'
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {

View File

@@ -289,7 +289,7 @@ watch(activeCategory, (_, oldValue) => {
display: flex;
height: 70vh;
width: 60vw;
max-width: 1024px;
max-width: 64rem;
overflow: hidden;
}

View File

@@ -0,0 +1,207 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
<div
class="flex-1 overflow-auto"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen,
'pr-80': showInfoPanel
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<RegistrySearchBar
v-if="!hideSearchBar"
v-model:searchQuery="searchQuery"
:searchResults="searchResults"
@update:sortBy="handleSortChange"
@update:filterBy="handleFilterChange"
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading || isInitialLoad"
class="flex justify-center items-center h-full"
>
<ProgressSpinner />
</div>
<NoResultsPlaceholder
v-else-if="error || searchResults.length === 0"
:title="
error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:defaultItemSize="DEFAULT_CARD_SIZE"
class="p-0 m-0 max-w-full"
:buffer-rows="2"
:gridStyle="{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${DEFAULT_CARD_SIZE}px, 1fr))`,
padding: '0.5rem',
gap: '1.125rem 1.25rem',
justifyContent: 'stretch'
}"
>
<template #item="{ item }">
<div
class="relative w-full aspect-square cursor-pointer"
@click.stop="(event) => selectNodePack(item, event)"
>
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
/>
</div>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div
v-if="showInfoPanel"
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
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 { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import type { PackField, TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const hideSearchBar = computed(() => isSmallScreen.value && showInfoPanel.value)
const tabs = ref<TabItem[]>([
{ id: 'all', label: 'All', icon: 'pi-list' },
{ id: 'community', label: 'Community', icon: 'pi-globe' },
{ id: 'installed', label: 'Installed', icon: 'pi-box' }
])
const selectedTab = ref<TabItem>(tabs.value[0])
const handleTabSelection = (tab: TabItem) => {
selectedTab.value = tab
}
const { searchQuery, pageNumber, sortField, isLoading, error, searchResults } =
useRegistrySearch()
pageNumber.value = 1
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const resultsWithKeys = computed(() =>
searchResults.value.map((item) => ({
...item,
key: item.id || item.name
}))
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const showInfoPanel = computed(() => selectedNodePacks.value.length > 0)
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
const currentFilterBy = ref('all')
const handleSortChange = (sortBy: PackField) => {
sortField.value = sortBy
}
const handleFilterChange = (filterBy: PackField) => {
currentFilterBy.value = filterBy
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -0,0 +1,50 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
>
<ScrollPanel class="w-80 mt-7">
<Listbox
:model-value="selectedTab"
:options="tabs"
optionLabel="label"
listStyle="max-height:unset"
:pt="{
root: { class: 'w-full border-0 bg-transparent' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
@update:model-value="handleTabSelection"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
selectedTab: TabItem
}>()
const emit = defineEmits<{
'update:selectedTab': [value: TabItem]
}>()
const handleTabSelection = (tab: TabItem) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
severity="secondary"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
>
<span class="py-2.5 px-3">
{{ multi ? $t('manager.installSelected') : $t('g.install') }}
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
fullWidth?: boolean
multi?: boolean
}>()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
></i>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { components } from '@/types/comfyRegistryTypes'
import { VueSeverity } from '@/types/primeVueTypes'
const { t } = useI18n()
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type StatusProps = {
label: string
severity: VueSeverity
}
const { statusType } = defineProps<{
statusType: Status
}>()
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'danger'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'danger'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'danger'
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="relative">
<Button
v-if="displayVersion"
:label="displayVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
aria-haspopup="true"
@click="toggleVersionSelector"
/>
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
}"
>
<PackVersionSelectorPopover
:selected-version="selectedVersion"
:node-pack="nodePack"
@select="onSelect"
@cancel="closeVersionSelector"
@apply="applyVersionSelection"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, version = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
version?: string
}>()
const emit = defineEmits<{
'update:version': [version: string]
}>()
const popoverRef = ref()
const selectedVersion = ref<string>(version)
const displayVersion = computed(() => {
if (selectedVersion.value === SelectedVersion.LATEST) {
// If there is no version, treat as unclaimed GitHub pack and use nightly
return nodePack?.latest_version?.version || SelectedVersion.NIGHTLY
}
return selectedVersion.value
})
const toggleVersionSelector = (event: Event) => {
popoverRef.value.toggle(event)
}
const closeVersionSelector = () => {
popoverRef.value.hide()
}
const onSelect = (newVersion: string) => {
selectedVersion.value = newVersion
}
const applyVersionSelection = (newVersion: string) => {
selectedVersion.value = newVersion
emit('update:version', newVersion)
// TODO: after manager store added, install the pack here
closeVersionSelector()
}
whenever(() => version, onSelect)
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div
v-if="isLoading"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="allVersionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
icon="pi pi-exclamation-circle"
class="p-0"
/>
</div>
<Listbox
v-else
v-model="currentSelection"
option-label="label"
option-value="value"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<i
v-if="currentSelection === slotProps.option.value"
class="pi pi-check text-highlight"
></i>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<Button
text
severity="secondary"
:label="$t('g.cancel')"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
@click="emit('apply', currentSelection ?? SelectedVersion.LATEST)"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack, selectedVersion = SelectedVersion.NIGHTLY } = defineProps<{
nodePack: components['schemas']['Node']
selectedVersion?: string
}>()
const emit = defineEmits<{
cancel: []
apply: [version: string]
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const currentSelection = ref<string>(selectedVersion)
const fetchVersions = async () => {
if (!nodePack?.id) return []
return (await registryService.getPackVersions(nodePack.id)) || []
}
const {
isLoading,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const specialOptions = computed(() => [
{
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
},
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
])
const versionOptions = computed(() =>
versions.value.map((version) => ({
value: version.version,
label: version.version
}))
)
const allVersionOptions = computed(() => [
...specialOptions.value,
...versionOptions.value
])
watch(
() => nodePack,
() => startFetchVersions(),
{ deep: true }
)
</script>

View File

@@ -0,0 +1,159 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Popover from 'primevue/popover'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from '../PackVersionBadge.vue'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0'
}
}
describe('PackVersionBadge', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Popover,
PackVersionSelectorPopover
}
}
})
}
it('renders with default version (NIGHTLY)', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('renders with provided version', () => {
const version = '2.0.0'
const wrapper = mountComponent({ props: { version } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(version)
})
it('shows actual latest (semantic) version prop when version is set to latest', () => {
const wrapper = mountComponent({
props: { version: SelectedVersion.LATEST }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(mockNodePack.latest_version.version)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Spy on the toggle method
const popoverToggleSpy = vi.fn()
const popover = wrapper.findComponent(Popover)
popover.vm.toggle = popoverToggleSpy
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Verify that the toggle method was called
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('emits update:version event when version is selected', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting an apply event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('apply', '3.0.0')
await nextTick()
// Check if the update:version event was emitted with the correct value
expect(wrapper.emitted('update:version')).toBeTruthy()
expect(wrapper.emitted('update:version')![0]).toEqual(['3.0.0'])
})
it('closes the popover when cancel is clicked', async () => {
const wrapper = mountComponent()
// Open the popover
await wrapper.findComponent(Button).trigger('click')
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Check if the popover is hidden
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('updates displayed version when version prop changes', async () => {
const wrapper = mountComponent({ props: { version: '1.0.0' } })
expect(wrapper.findComponent(Button).props('label')).toBe('1.0.0')
// Update the version prop
await wrapper.setProps({ version: '2.0.0' })
// Check if the displayed version was updated
expect(wrapper.findComponent(Button).props('label')).toBe('2.0.0')
})
it('handles null or undefined nodePack', async () => {
const wrapper = mountComponent({ props: { nodePack: null } })
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
// Should not crash when clicking the button
await button.trigger('click')
expect(wrapper.findComponent(Popover).isVisible()).toBe(false)
})
it('handles missing latest_version (unclaimed pack) by falling back to NIGHTLY', async () => {
const incompleteNodePack = { id: 'test-pack', name: 'Test Pack' }
const wrapper = mountComponent({
props: {
nodePack: incompleteNodePack,
version: SelectedVersion.LATEST
}
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
// Should fallback to nightly string when latest_version is missing
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
})

View File

@@ -0,0 +1,190 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
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 { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
const mockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
}
const mockGetPackVersions = vi.fn().mockResolvedValue(mockVersions)
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
}
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockClear()
mockGetPackVersions.mockResolvedValue(mockVersions)
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
selectedVersion: SelectedVersion.NIGHTLY,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
}
}
})
}
it('fetches versions on mount', async () => {
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) => setTimeout(() => resolve(mockVersions), 1000))
)
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
expect(options.length).toBe(5) // 2 special options + 3 version options
// Check special options
expect(options[0].value).toBe(SelectedVersion.NIGHTLY)
expect(options[1].value).toBe(SelectedVersion.LATEST)
// Check version options
expect(options[2].value).toBe('1.0.0')
expect(options[3].value).toBe('0.9.0')
expect(options[4].value).toBe('0.8.0')
})
it('initializes with the provided selectedVersion prop', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
// Check that the listbox has the correct model value
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(selectedVersion)
})
it('emits cancel event when cancel button is clicked', async () => {
const wrapper = mountComponent()
await waitForPromises()
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('emits apply event with current selection when apply button is clicked', async () => {
const selectedVersion = '0.9.0'
const wrapper = mountComponent({ props: { selectedVersion } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([selectedVersion])
})
it('emits apply event with LATEST when no selection and apply button is clicked', async () => {
const wrapper = mountComponent({ props: { selectedVersion: null } })
await waitForPromises()
const applyButton = wrapper.findAllComponents(Button)[1]
await applyButton.trigger('click')
expect(wrapper.emitted('apply')).toBeTruthy()
expect(wrapper.emitted('apply')![0]).toEqual([SelectedVersion.LATEST])
})
it('is reactive to nodePack prop changes', async () => {
const wrapper = mountComponent()
await waitForPromises()
// Clear mock calls to check if getPackVersions is called again
mockGetPackVersions.mockClear()
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when comfy-api returns null when fetching versions', async () => {
mockGetPackVersions.mockResolvedValueOnce(null)
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to nightly when component mounts with no versions (unclaimed pack)', async () => {
mockGetPackVersions.mockResolvedValueOnce([])
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
})
})

View File

@@ -0,0 +1,114 @@
<template>
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<PackCardHeader
:node-pack="nodePack"
:install-button-full-width="false"
/>
<div class="mb-6">
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge
:node-pack="nodePack"
:version="selectedVersion"
@update:version="updateSelectedVersion"
/>
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { t, d } = useI18n()
const packCardHeaderRef = ref(null)
const selectedVersion = ref<string>(
nodePack.latest_version?.version || SelectedVersion.NIGHTLY
)
const updateSelectedVersion = (version: string) => {
selectedVersion.value = version
if (packCardHeaderRef.value) {
packCardHeaderRef.value.updateVersion?.(version)
}
}
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: t('manager.createdBy'),
// TODO: handle all Comfy Registry publisher types dynamically (e.g., organizations, multiple authors)
value: nodePack.publisher?.name
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? formatNumber(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? d(nodePack.latest_version.createdAt, {
dateStyle: 'medium'
})
: undefined
}
])
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<PackCardHeader>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :multi="true" />
</template>
</PackCardHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage status-type="NodeVersionStatusActive" />
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const comfyRegistryService = useComfyRegistryService()
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
})
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">{{ $t('g.description') }}</Tab>
<Tab value="nodes">{{ $t('g.nodes') }}</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { ref } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
}>()
const activeTab = ref('description')
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">{{ section.title }}</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2"
>
<i
v-if="isGitHubLink(section.text)"
class="pi pi-github text-base"
></i>
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/components/dialog/content/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div v-if="!hasMarkdown" v-text="text" class="break-words"></div>
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A', label = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import { components } from '@/types/comfyRegistryTypes'
import { isValidUrl } from '@/utils/formatUtil'
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
const licenseFile = licenseObj.file || licenseObj.text
if (
typeof licenseFile === 'string' &&
isLicenseFile(licenseFile) &&
nodePack.repository
) {
const url = createLicenseUrl(licenseFile, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
} else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
} else if (typeof licenseFile === 'string') {
// Return the license file name if repository is missing
return {
text: licenseFile,
isUrl: false
}
}
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (
license: string
): { text: string; isUrl: boolean } | null => {
// Treat "{}" JSON string as undefined
if (license === '{}') return null
try {
const licenseObj = JSON.parse(license)
// Handle empty object case
if (Object.keys(licenseObj).length === 0) {
return null
}
return parseLicenseObject(licenseObj)
} catch (e) {
if (isLicenseFile(license) && nodePack.repository) {
const url = createLicenseUrl(license, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: nodePack.description || t('manager.noDescription')
}
]
if (nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: nodePack.repository,
isUrl: isValidUrl(nodePack.repository)
})
}
if (nodePack.license) {
const licenseInfo = formatLicense(nodePack.license)
if (licenseInfo && licenseInfo.text) {
sections.push({
title: t('manager.license'),
text: licenseInfo.text,
isUrl: licenseInfo.isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 mt-4 overflow-auto text-sm">
<div v-if="nodeDefs?.length">
<!-- TODO: when registry returns node defs, use them here -->
</div>
<div
v-else
v-for="i in 3"
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
nodeDefs?: components['schemas']['ComfyNode'][]
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
input1: { name: 'Input 1', type: 'IMAGE' },
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
}
</script>

View File

@@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from '../DescriptionTabPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
const TRANSLATIONS = {
description: 'Description',
repository: 'Repository',
license: 'License',
noDescription: 'No description available'
}
describe('DescriptionTabPanel', () => {
const mountComponent = (props: {
nodePack: Partial<components['schemas']['Node']>
}) => {
return mount(DescriptionTabPanel, {
props,
global: {
plugins: [i18n]
}
})
}
const getSectionByTitle = (
wrapper: ReturnType<typeof mountComponent>,
title: string
) => {
const sections = wrapper
.findComponent({ name: 'InfoTextSection' })
.props('sections')
return sections.find((s: any) => s.title === title)
}
const createNodePack = (
overrides: Partial<components['schemas']['Node']> = {}
) => ({
description: 'Test description',
...overrides
})
const licenseTests = [
{
name: 'handles plain text license',
nodePack: createNodePack({
license: 'MIT License',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'MIT License',
isUrl: false
}
},
{
name: 'handles license file names',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE',
isUrl: true
}
},
{
name: 'handles license.md file names',
nodePack: createNodePack({
license: 'license.md',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/license.md',
isUrl: true
}
},
{
name: 'handles JSON license objects with text property',
nodePack: createNodePack({
license: JSON.stringify({ text: 'GPL-3.0' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'GPL-3.0',
isUrl: false
}
},
{
name: 'handles JSON license objects with file property',
nodePack: createNodePack({
license: JSON.stringify({ file: 'LICENSE.md' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE.md',
isUrl: true
}
},
{
name: 'handles missing repository URL',
nodePack: createNodePack({
license: 'LICENSE'
}),
expected: {
text: 'LICENSE',
isUrl: false
}
},
{
name: 'handles non-GitHub repository URLs',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://gitlab.com/user/repo'
}),
expected: {
text: 'https://gitlab.com/user/repo/blob/main/LICENSE',
isUrl: true
}
}
]
describe('license formatting', () => {
licenseTests.forEach((test) => {
it(test.name, () => {
const wrapper = mountComponent({ nodePack: test.nodePack })
const licenseSection = getSectionByTitle(wrapper, TRANSLATIONS.license)
expect(licenseSection).toBeDefined()
expect(licenseSection.text).toBe(test.expected.text)
expect(licenseSection.isUrl).toBe(test.expected.isUrl)
})
})
})
describe('description sections', () => {
it('shows description section', () => {
const wrapper = mountComponent({
nodePack: createNodePack()
})
const descriptionSection = getSectionByTitle(
wrapper,
TRANSLATIONS.description
)
expect(descriptionSection).toBeDefined()
expect(descriptionSection.text).toBe('Test description')
})
it('shows repository section when available', () => {
const wrapper = mountComponent({
nodePack: createNodePack({
repository: 'https://github.com/user/repo'
})
})
const repoSection = getSectionByTitle(wrapper, TRANSLATIONS.repository)
expect(repoSection).toBeDefined()
expect(repoSection.text).toBe('https://github.com/user/repo')
expect(repoSection.isUrl).toBe(true)
})
it('shows fallback text when description is missing', () => {
const wrapper = mountComponent({
nodePack: {
description: undefined
}
})
expect(wrapper.find('p').text()).toBe(TRANSLATIONS.noDescription)
})
})
})

View File

@@ -0,0 +1,116 @@
<template>
<Card
class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-elevation-4 dark-theme:bg-dark-elevation-1 transition-all duration-200"
:class="{
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
></i>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2"></i>
{{ formatNumber(nodePack.downloads) }}
</div>
<PackInstallButton />
</div>
</div>
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
</div>
</div>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<div class="flex justify-between p-5 text-xs text-muted">
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<PackVersionBadge
v-if="isPackInstalled"
:node-pack="nodePack"
v-model:version="selectedVersion"
/>
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
<div
v-if="nodePack.latest_version"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
{{
$d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
}}
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const selectedVersion = ref<string | undefined>(undefined)
const isPackInstalled = computed(
() =>
// TODO: after manager store added
// managerStore.isPackInstalled(nodePack?.id)
true
)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePack" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">{{ nodePack?.name }}</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackInstallButton
:full-width="installButtonFullWidth"
:multi="multi"
/>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack?: components['schemas']['Node']
multi?: boolean
installButtonFullWidth?: boolean
}>()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="relative w-24 h-24">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="relative w-full p-6">
<div class="flex items-center w-full">
<IconField class="w-5/12">
<InputIcon class="pi pi-search" />
<InputText
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
:placeholder="$t('manager.searchPlaceholder')"
class="w-full rounded-2xl"
autofocus
/>
</IconField>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model="currentFilter"
:options="filterOptions"
:label="$t('g.filter')"
@update:model-value="handleFilterChange"
/>
<SearchFilterDropdown
v-model="currentSort"
:options="sortOptions"
:label="$t('g.sort')"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults.length }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { PackField, SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_SORT: PackField = 'downloads'
const DEFAULT_FILTER = 'nodePack'
const props = defineProps<{
searchQuery: string
searchResults?: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const currentSort = ref<PackField>(DEFAULT_SORT)
const currentFilter = ref<string>(DEFAULT_FILTER)
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: PackField]
'update:filterBy': [value: string]
}>()
const hasResults = computed(
() => props.searchQuery.trim() && props.searchResults?.length
)
const sortOptions: SearchOption<PackField>[] = [
{ id: 'downloads', label: t('manager.sort.downloads') },
{ id: 'name', label: t('g.name') },
{ id: 'rating', label: t('manager.sort.rating') },
{ id: 'category', label: t('g.category') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'nodePack', label: t('manager.filter.nodePack') },
{ id: 'node', label: t('g.nodes') }
]
const handleSortChange = () => {
// TODO: emit to Algolia service
emit('update:sortBy', currentSort.value)
}
const handleFilterChange = () => {
// TODO: emit to Algolia service
emit('update:filterBy', currentFilter.value)
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
v-model="selectedValue"
:options="options"
optionLabel="label"
optionValue="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
@change="handleChange"
:pt="{
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
/>
</div>
</template>
<script setup lang="ts" generic="T">
import Dropdown from 'primevue/dropdown'
import { computed } from 'vue'
import type { SearchOption } from '@/types/comfyManagerTypes'
const { modelValue, options, label } = defineProps<{
modelValue: T
options: SearchOption<T>[]
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
const selectedValue = computed({
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
const handleChange = () => {
emit('update:modelValue', selectedValue.value)
}
</script>

View File

@@ -1,22 +1,78 @@
<template>
<div>
<DomWidget v-for="widget in widgets" :key="widget.id" :widget="widget" />
<!-- Create a new stacking context for widgets to avoid z-index issues -->
<div class="isolate">
<DomWidget
v-for="widget in widgets"
:key="widget.id"
:widget="widget"
:widget-state="domWidgetStore.widgetStates.get(widget.id)"
@update:widget-value="widget.value = $event"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, watch } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { DOMWidget } from '@/scripts/domWidget'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgets = computed(() =>
Array.from(
domWidgetStore.widgetInstances.values() as Iterable<
DOMWidget<HTMLElement, object | string>
BaseDOMWidget<string | object>
>
)
)
const MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widget of domWidgetStore.widgetInstances.values()) {
const node = widget.node as LGraphNode
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) continue
const visible =
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
widgetState.visible = visible
if (visible) {
widgetState.pos = [node.pos[0] + MARGIN, node.pos[1] + MARGIN + widget.y]
widgetState.size = [
(widget.width ?? node.width) - MARGIN * 2,
(widget.computedHeight ?? 50) - MARGIN * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)
widgetState.readonly = lgCanvas.read_only
}
}
}
const canvasStore = useCanvasStore()
watch(
() => canvasStore.canvas,
(lgCanvas) => {
if (!lgCanvas) return
lgCanvas.onDrawForeground = useChainCallback(
lgCanvas.onDrawForeground,
() => {
updateWidgets()
}
)
}
)
</script>

View File

@@ -20,6 +20,7 @@ import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { app as comfyApp } from '@/scripts/app'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -101,7 +102,7 @@ const onIdle = () => {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip

View File

@@ -1,19 +1,143 @@
<template>
<div ref="widgetElement" />
<div
class="dom-widget"
:title="tooltip"
ref="widgetElement"
:style="style"
v-show="widgetState.visible"
>
<component
v-if="isComponentWidget(widget)"
:is="widget.component"
:modelValue="widget.value"
@update:modelValue="emit('update:widgetValue', $event)"
:widget="widget"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import {
type BaseDOMWidget,
isComponentWidget,
isDOMWidget
} from '@/scripts/domWidget'
import { DomWidgetState } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { widget } = defineProps<{
widget: DOMWidget<HTMLElement, any>
const { widget, widgetState } = defineProps<{
widget: BaseDOMWidget<string | object>
widgetState: DomWidgetState
}>()
const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
}>()
const widgetElement = ref<HTMLElement>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
const { style: clippingStyle, updateClipPath } = useDomClipping()
const style = computed<CSSProperties>(() => ({
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly ? 'none' : 'auto'
}))
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const enableDomClipping = computed(() =>
settingStore.get('Comfy.DOMClippingEnabled')
)
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
const selectedAreaConfig = renderArea
? {
x: renderArea[0],
y: renderArea[1],
width: renderArea[2],
height: renderArea[3],
scale,
offset: [offset[0], offset[1]] as [number, number]
}
: undefined
updateClipPath(
widgetElement.value,
lgCanvas.canvas,
isSelected,
selectedAreaConfig
)
}
watch(
() => widgetState,
(newState) => {
updatePositionWithTransform(newState)
if (enableDomClipping.value) {
updateDomClipping()
}
},
{ deep: true }
)
watch(
() => widgetState.visible,
(newVisible, oldVisible) => {
if (!newVisible && oldVisible) {
widget.options.onHide?.(widget)
}
}
)
if (isDOMWidget(widget)) {
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
}
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
useEventListener(widget.element, evt, () => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
})
}
}
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
widgetElement.value.appendChild(widget.element)
if (isDOMWidget(widget)) {
widgetElement.value.appendChild(widget.element)
}
})
</script>
<style scoped>
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div>
<MultiSelect
v-model="selectedItems"
:options="options"
filter
:placeholder="placeholder"
:maxSelectedLabels="3"
:display="display"
class="w-full"
/>
</div>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const selectedItems = defineModel<string[]>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
const inputSpec = widget.inputSpec as ComboInputSpec
const options = inputSpec.options ?? []
const placeholder = inputSpec.multi_select?.placeholder ?? 'Select items'
const display = inputSpec.multi_select?.chip ? 'chip' : 'comma'
</script>

View File

@@ -12,6 +12,7 @@
:backgroundImage="backgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:edgeThreshold="edgeThreshold"
@materialModeChange="listenMaterialModeChange"
@backgroundColorChange="listenBackgroundColorChange"
@lightIntensityChange="listenLightIntensityChange"
@@ -21,6 +22,7 @@
@showPreviewChange="listenShowPreviewChange"
@backgroundImageChange="listenBackgroundImageChange"
@upDirectionChange="listenUpDirectionChange"
@edgeThresholdChange="listenEdgeThresholdChange"
/>
<Load3DControls
:backgroundColor="backgroundColor"
@@ -36,6 +38,7 @@
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="false"
:edgeThreshold="edgeThreshold"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@toggleGrid="toggleGrid"
@@ -45,6 +48,7 @@
@updateFOV="handleUpdateFOV"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
/>
</div>
</template>
@@ -79,6 +83,7 @@ const hasBackgroundImage = ref(false)
const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const showPreviewButton = computed(() => {
return !props.type.includes('Preview')
@@ -130,6 +135,12 @@ const handleUpdateFOV = (value: number) => {
node.value.properties['FOV'] = fov.value
}
const handleUpdateEdgeThreshold = (value: number) => {
edgeThreshold.value = value
node.value.properties['Edge Threshold'] = edgeThreshold.value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
@@ -158,6 +169,10 @@ const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenEdgeThresholdChange = (value: number) => {
edgeThreshold.value = value
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}

View File

@@ -30,210 +30,46 @@
</div>
<div v-show="activeCategory" class="bg-gray-700 bg-opacity-30 rounded-lg">
<div v-if="activeCategory === 'scene'" class="flex flex-col">
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
>
<i
class="pi pi-table text-white text-lg"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
></i>
</Button>
<SceneControls
v-if="activeCategory === 'scene'"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:hasBackgroundImage="hasBackgroundImage"
@toggleGrid="handleToggleGrid"
@updateBackgroundColor="handleBackgroundColorChange"
@updateBackgroundImage="handleBackgroundImageUpdate"
ref="sceneControlsRef"
/>
<div v-if="!hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="openColorPicker"
>
<i
class="pi pi-palette text-white text-lg"
v-tooltip.right="{
value: t('load3d.backgroundColor'),
showDelay: 300
}"
></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<ModelControls
v-if="activeCategory === 'model'"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="isAnimation"
:edgeThreshold="edgeThreshold"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
ref="modelControlsRef"
/>
<div v-if="!hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="openImagePicker"
>
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
></i>
<input
type="file"
ref="imagePickerRef"
accept="image/*"
@change="uploadBackgroundImage"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<CameraControls
v-if="activeCategory === 'camera'"
:cameraType="cameraType"
:fov="fov"
:showFOVButton="showFOVButton"
@switchCamera="switchCamera"
@updateFOV="handleUpdateFOV"
ref="cameraControlsRef"
/>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="removeBackgroundImage"
>
<i
class="pi pi-times text-white text-lg"
v-tooltip.right="{
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
></i>
</Button>
</div>
</div>
<div v-if="activeCategory === 'model'" class="flex flex-col">
<div class="relative show-up-direction">
<Button
class="p-button-rounded p-button-text"
@click="toggleUpDirection"
>
<i
class="pi pi-arrow-up text-white text-lg"
v-tooltip.right="{
value: t('load3d.upDirection'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showUpDirection"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="direction in upDirections"
:key="direction"
class="p-button-text text-white"
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ formatOption(direction) }}
</Button>
</div>
</div>
</div>
<div class="relative show-material-mode">
<Button
class="p-button-rounded p-button-text"
@click="toggleMaterialMode"
>
<i
class="pi pi-box text-white text-lg"
v-tooltip.right="{
value: t('load3d.materialMode'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showMaterialMode"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
{{ formatMaterialMode(mode) }}
</Button>
</div>
</div>
</div>
</div>
<div v-if="activeCategory === 'camera'" class="flex flex-col">
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
:class="['pi', getCameraIcon, 'text-white text-lg']"
v-tooltip.right="{
value: t('load3d.switchCamera'),
showDelay: 300
}"
></i>
</Button>
<div class="relative show-fov" v-if="showFOVButton">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
class="pi pi-expand text-white text-lg"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
></i>
</Button>
<div
v-show="showFOV"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="fov"
class="w-full"
@change="updateFOV"
:min="10"
:max="150"
:step="1"
/>
</div>
</div>
</div>
<div v-if="activeCategory === 'light'" class="flex flex-col">
<div
class="relative show-light-intensity"
v-if="showLightIntensityButton"
>
<Button
class="p-button-rounded p-button-text"
@click="toggleLightIntensity"
>
<i
class="pi pi-sun text-white text-lg"
v-tooltip.right="{
value: t('load3d.lightIntensity'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showLightIntensity"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="lightIntensity"
class="w-full"
@change="updateLightIntensity"
:min="1"
:max="20"
:step="1"
/>
</div>
</div>
</div>
<LightControls
v-if="activeCategory === 'light'"
:lightIntensity="lightIntensity"
:showLightIntensityButton="showLightIntensityButton"
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -253,9 +89,12 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.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'
import {
CameraType,
MaterialMode,
@@ -279,6 +118,7 @@ const props = defineProps<{
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
@@ -319,6 +159,7 @@ const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -335,8 +176,6 @@ const showFOV = ref(false)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const imagePickerRef = ref<HTMLInputElement | null>(null)
const showUpDirection = ref(false)
const upDirections: UpDirection[] = [
'original',
'-x',
@@ -346,36 +185,12 @@ const upDirections: UpDirection[] = [
'-z',
'+z'
]
const showMaterialMode = ref(false)
const materialModes = computed(() => {
const modes: MaterialMode[] = [
'original',
'normal',
'wireframe'
//'depth' disable for now
]
if (!props.isAnimation) {
modes.push('lineart')
}
return modes
})
const notMaterialLineart = computed(() => {
return props.materialMode !== 'lineart'
})
const edgeThreshold = ref(props.edgeThreshold)
const switchCamera = () => {
emit('switchCamera')
}
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const togglePreview = () => {
showPreview.value = !showPreview.value
emit('togglePreview', showPreview.value)
@@ -385,51 +200,48 @@ const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
const handleToggleGrid = (value: boolean) => {
emit('toggleGrid', value)
}
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
const handleBackgroundColorChange = (value: string) => {
emit('updateBackgroundColor', value)
}
const handleBackgroundImageUpdate = (file: File | null) => {
emit('updateBackgroundImage', file)
}
const handleUpdateUpDirection = (direction: UpDirection) => {
emit('updateUpDirection', direction)
}
const handleUpdateMaterialMode = (mode: MaterialMode) => {
emit('updateMaterialMode', mode)
}
const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
}
const formatMaterialMode = (mode: MaterialMode) => {
return mode.charAt(0).toUpperCase() + mode.slice(1)
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSlider = (e: MouseEvent) => {
@@ -438,26 +250,6 @@ const closeSlider = (e: MouseEvent) => {
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
if (!target.closest('.show-fov')) {
showFOV.value = false
}
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
}
if (!target.closest('.show-up-direction')) {
showUpDirection.value = false
}
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
}
const openImagePicker = () => {
imagePickerRef.value?.click()
}
watch(
@@ -469,22 +261,6 @@ watch(
}
)
const uploadBackgroundImage = (event: Event) => {
const input = event.target as HTMLInputElement
hasBackgroundImage.value = true
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
}
const removeBackgroundImage = () => {
hasBackgroundImage.value = false
emit('updateBackgroundImage', null)
}
watch(
() => props.backgroundColor,
(newValue) => {
@@ -564,6 +340,13 @@ watch(
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
onMounted(() => {
document.addEventListener('click', closeSlider)
})
@@ -571,8 +354,4 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
</script>

View File

@@ -31,6 +31,7 @@ const props = defineProps<{
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
extraListeners?: Record<string, (value: any) => void>
}>()
@@ -51,6 +52,7 @@ const eventConfig = {
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
upDirectionChange: (value: string) => emit('upDirectionChange', value),
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
modelLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
@@ -85,6 +87,17 @@ watch(
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
rawLoad3d.setEdgeThreshold(newValue)
}
}
)
const emit = defineEmits<{
(e: 'materialModeChange', materialMode: string): void
(e: 'backgroundColorChange', color: string): void
@@ -95,6 +108,7 @@ const emit = defineEmits<{
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'backgroundImageChange', backgroundImage: string): void
(e: 'upDirectionChange', upDirection: string): void
(e: 'edgeThresholdChange', threshold: number): void
}>()
const handleEvents = (action: 'add' | 'remove') => {

View File

@@ -0,0 +1,116 @@
<template>
<div class="flex flex-col">
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
:class="['pi', getCameraIcon, 'text-white text-lg']"
v-tooltip.right="{
value: t('load3d.switchCamera'),
showDelay: 300
}"
></i>
</Button>
<div class="relative show-fov" v-if="showFOVButton">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
class="pi pi-expand text-white text-lg"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
></i>
</Button>
<div
v-show="showFOV"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="fov"
class="w-full"
@change="updateFOV"
:min="10"
:max="150"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
cameraType: CameraType
fov: number
showFOVButton: boolean
}>()
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'updateFOV', value: number): void
}>()
const cameraType = ref(props.cameraType)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showFOV = ref(false)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
const switchCamera = () => {
emit('switchCamera')
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
const closeCameraSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-fov')) {
showFOV.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeCameraSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeCameraSlider)
})
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex flex-col">
<div class="relative show-light-intensity" v-if="showLightIntensityButton">
<Button
class="p-button-rounded p-button-text"
@click="toggleLightIntensity"
>
<i
class="pi pi-sun text-white text-lg"
v-tooltip.right="{
value: t('load3d.lightIntensity'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showLightIntensity"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<Slider
v-model="lightIntensity"
class="w-full"
@change="updateLightIntensity"
:min="1"
:max="20"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
lightIntensity: number
showLightIntensityButton: boolean
}>()
const emit = defineEmits<{
(e: 'updateLightIntensity', value: number): void
}>()
const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const showLightIntensity = ref(false)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const closeLightSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeLightSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeLightSlider)
})
</script>

View File

@@ -0,0 +1,242 @@
<template>
<div class="flex flex-col">
<div class="relative show-up-direction">
<Button class="p-button-rounded p-button-text" @click="toggleUpDirection">
<i
class="pi pi-arrow-up text-white text-lg"
v-tooltip.right="{
value: t('load3d.upDirection'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showUpDirection"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="direction in upDirections"
:key="direction"
class="p-button-text text-white"
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ formatOption(direction) }}
</Button>
</div>
</div>
</div>
<div class="relative show-material-mode">
<Button
class="p-button-rounded p-button-text"
@click="toggleMaterialMode"
>
<i
class="pi pi-box text-white text-lg"
v-tooltip.right="{
value: t('load3d.materialMode'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showMaterialMode"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
{{ formatMaterialMode(mode) }}
</Button>
</div>
</div>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@click="toggleEdgeThreshold"
>
<i
class="pi pi-sliders-h text-white text-lg"
v-tooltip.right="{
value: t('load3d.edgeThreshold'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showEdgeThreshold"
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
style="width: 150px"
>
<label class="text-white text-xs mb-1 block"
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
>
<Slider
v-model="edgeThreshold"
class="w-full"
@change="updateEdgeThreshold"
:min="0"
:max="120"
:step="1"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const upDirections: UpDirection[] = [
'original',
'-x',
'+x',
'-y',
'+y',
'-z',
'+z'
]
const materialModes = computed(() => {
const modes: MaterialMode[] = [
'original',
'normal',
'wireframe'
//'depth' disable for now
]
if (!props.isAnimation) {
modes.push('lineart')
}
return modes
})
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
showMaterialMode.value = false
showEdgeThreshold.value = false
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
showUpDirection.value = false
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
showUpDirection.value = false
showEdgeThreshold.value = false
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
showMaterialMode.value = false
}
const formatMaterialMode = (mode: MaterialMode) => {
return mode.charAt(0).toUpperCase() + mode.slice(1)
}
const toggleEdgeThreshold = () => {
showEdgeThreshold.value = !showEdgeThreshold.value
showUpDirection.value = false
showMaterialMode.value = false
}
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-up-direction')) {
showUpDirection.value = false
}
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
if (!target.closest('.show-edge-threshold')) {
showEdgeThreshold.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeSceneSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSceneSlider)
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div class="flex flex-col">
<Button
class="p-button-rounded p-button-text"
:class="{ 'p-button-outlined': showGrid }"
@click="toggleGrid"
>
<i
class="pi pi-table text-white text-lg"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
></i>
</Button>
<div v-if="!hasBackgroundImage">
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i
class="pi pi-palette text-white text-lg"
v-tooltip.right="{
value: t('load3d.backgroundColor'),
showDelay: 300
}"
></i>
<input
type="color"
ref="colorPickerRef"
:value="backgroundColor"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
></i>
<input
type="file"
ref="imagePickerRef"
accept="image/*"
@change="uploadBackgroundImage"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="removeBackgroundImage"
>
<i
class="pi pi-times text-white text-lg"
v-tooltip.right="{
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
></i>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
backgroundColor: string
showGrid: boolean
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateBackgroundImage', file: File | null): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const imagePickerRef = ref<HTMLInputElement | null>(null)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const updateBackgroundColor = (color: string) => {
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {
colorPickerRef.value?.click()
}
const openImagePicker = () => {
imagePickerRef.value?.click()
}
const uploadBackgroundImage = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
}
const removeBackgroundImage = () => {
emit('updateBackgroundImage', null)
}
</script>

View File

@@ -76,17 +76,34 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
>
{{ nodeDef.description }}
</div>
<Select
v-model="selectedSpeed"
:options="speedOptions"
optionLabel="name"
optionValue="value"
/>
</div>
</template>
<script setup lang="ts">
import _ from 'lodash'
import { computed } from 'vue'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const selectedSpeed = ref(1)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
{ name: '0.5x', value: 0.5 },
{ name: '1x', value: 1 },
{ name: '1.5x', value: 1.5 },
{ name: '2x', value: 2 }
]
const props = defineProps<{
nodeDef: ComfyNodeDefV2
}>()

View File

@@ -2,7 +2,7 @@
<Card
ref="cardRef"
:data-testid="`template-workflow-${template.name}`"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-[0_10px_15px_-3px_rgba(0,0,0,0.08),0_4px_6px_-4px_rgba(0,0,0,0.05)]"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1"
:pt="{
body: { class: 'p-0' }
}"
@@ -103,8 +103,8 @@ const isHovered = useElementHover(cardRef)
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? `/templates/${template.name}`
: `/api/workflow_templates/${sourceModule}/${template.name}`
? `templates/${template.name}`
: `api/workflow_templates/${sourceModule}/${template.name}`
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''

View File

@@ -23,6 +23,12 @@ export function useAbsolutePosition() {
height: '0px'
})
/**
* Update the position of the element on the litegraph canvas.
*
* @param config
* @param extraStyle
*/
const updatePosition = (
config: PositionConfig,
extraStyle?: CSSProperties
@@ -41,8 +47,36 @@ export function useAbsolutePosition() {
}
}
/**
* Update the position and size of the element on the litegraph canvas,
* with CSS transform scaling applied.
*
* @param config
* @param extraStyle
*/
const updatePositionWithTransform = (
config: PositionConfig,
extraStyle?: CSSProperties
) => {
const { pos, size, scale = canvasStore.canvas?.ds?.scale ?? 1 } = config
const [left, top] = app.canvasPosToClientPos(pos)
const [width, height] = size
style.value = {
...style.value,
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
...extraStyle
}
}
return {
style,
updatePosition
updatePosition,
updatePositionWithTransform
}
}

View File

@@ -0,0 +1,123 @@
import { CSSProperties, ref } from 'vue'
interface Rect {
x: number
y: number
width: number
height: number
}
/**
* Finds the intersection between two rectangles
*/
function intersect(a: Rect, b: Rect): [number, number, number, number] | null {
const x1 = Math.max(a.x, b.x)
const y1 = Math.max(a.y, b.y)
const x2 = Math.min(a.x + a.width, b.x + b.width)
const y2 = Math.min(a.y + a.height, b.y + b.height)
if (x1 >= x2 || y1 >= y2) {
return null
}
return [x1, y1, x2 - x1, y2 - y1]
}
export interface ClippingOptions {
margin?: number
}
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options
/**
* Calculates a clip path for an element based on its intersection with a selected area
*/
const calculateClipPath = (
elementRect: DOMRect,
canvasRect: DOMRect,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
): string => {
if (!isSelected && selectedArea) {
const { scale, offset } = selectedArea
// Get intersection in browser space
const intersection = intersect(
{
x: elementRect.left - canvasRect.left,
y: elementRect.top - canvasRect.top,
width: elementRect.width,
height: elementRect.height
},
{
x: (selectedArea.x + offset[0] - margin) * scale,
y: (selectedArea.y + offset[1] - margin) * scale,
width: (selectedArea.width + 2 * margin) * scale,
height: (selectedArea.height + 2 * margin) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elementRect.left + canvasRect.left) / scale + 'px'
const clipY =
(intersection[1] - elementRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
return `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
}
return ''
}
/**
* Updates the clip-path style based on element and selection information
*/
const updateClipPath = (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => {
const elementRect = element.getBoundingClientRect()
const canvasRect = canvasElement.getBoundingClientRect()
const clipPath = calculateClipPath(
elementRect,
canvasRect,
isSelected,
selectedArea
)
style.value = {
clipPath: clipPath || 'none',
willChange: 'clip-path'
}
}
return {
style,
updateClipPath
}
}

View File

@@ -0,0 +1,68 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ANIM_PREVIEW_WIDGET } from '@/scripts/app'
import { createImageHost } from '@/scripts/ui/imagePreview'
/**
* Composable for handling animated image previews in nodes
*/
export function useNodeAnimatedImage() {
/**
* Shows animated image preview for a node
* @param node The graph node to show the preview for
*/
function showAnimatedPreview(node: LGraphNode) {
if (!node.imgs?.length) return
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
// Replace content in existing widget
const widget = node.widgets[widgetIdx] as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.options.host.updateImages(node.imgs)
} else {
// Create new widget
const host = createImageHost(node)
// @ts-expect-error host is not a standard DOM widget option.
const widget = node.addDOMWidget(ANIM_PREVIEW_WIDGET, 'img', host.el, {
host,
// @ts-expect-error `getHeight` of image host returns void instead of number.
getHeight: host.getHeight,
onDraw: host.onDraw,
hideOnZoom: false
}) as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.serializeValue = () => undefined
widget.options.host.updateImages(node.imgs)
}
}
/**
* Removes animated image preview from a node
* @param node The graph node to remove the preview from
*/
function removeAnimatedPreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showAnimatedPreview,
removeAnimatedPreview
}
}

View File

@@ -0,0 +1,48 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
/**
* Composable for handling canvas image previews in nodes
*/
export function useNodeCanvasImagePreview() {
const imagePreviewWidget = useImagePreviewWidget()
/**
* Shows canvas image preview for a node
* @param node The graph node to show the preview for
*/
function showCanvasImagePreview(node: LGraphNode) {
if (!node.imgs?.length) return
if (!node.widgets?.find((w) => w.name === CANVAS_IMAGE_PREVIEW_WIDGET)) {
imagePreviewWidget(node, {
type: 'IMAGE_PREVIEW',
name: CANVAS_IMAGE_PREVIEW_WIDGET
})
}
}
/**
* Removes canvas image preview from a node
* @param node The graph node to remove the preview from
*/
function removeCanvasImagePreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showCanvasImagePreview,
removeCanvasImagePreview
}
}

View File

@@ -111,7 +111,6 @@ export const useNodeImage = (node: LGraphNode) => {
const onLoaded = (elements: HTMLImageElement[]) => {
node.imageIndex = null
node.imgs = elements
node.setSizeForImage?.()
}
return useNodePreview(node, {
@@ -159,7 +158,6 @@ export const useNodeVideo = (node: LGraphNode) => {
node.videoContainer.replaceChildren(videoElement)
node.imageOffset = VIDEO_PIXEL_OFFSET
node.setSizeForImage?.(true)
}
return useNodePreview(node, {

View File

@@ -0,0 +1,95 @@
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
type ModelType =
| 'Checkpoint'
| 'TextualInversion'
| 'Hypernetwork'
| 'AestheticGradient'
| 'LORA'
| 'Controlnet'
| 'Poses'
interface CivitaiFileMetadata {
fp?: 'fp16' | 'fp32'
size?: 'full' | 'pruned'
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
}
interface CivitaiModelFile {
name: string
id: number
sizeKB: number
type: string
downloadUrl: string
metadata: CivitaiFileMetadata
}
interface CivitaiModel {
name: string
type: ModelType
}
interface CivitaiModelVersionResponse {
id: number
name: string
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: any
}
/**
* Composable to manage Civitai model
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
* @see https://developer.civitai.com/docs/api/public-rest
* @example
* const { fileSize, isLoading, error, modelData } =
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
*/
export function useCivitaiModel(url: string) {
const createModelVersionUrl = (modelId: string): string =>
`https://civitai.com/api/v1/model-versions/${modelId}`
const extractModelIdFromUrl = (): string | null => {
const urlObj = new URL(url)
return urlObj.pathname.split('/').pop() || null
}
const fetchModelData =
async (): Promise<CivitaiModelVersionResponse | null> => {
const modelId = extractModelIdFromUrl()
if (!modelId) return null
const apiUrl = createModelVersionUrl(modelId)
const res = await fetch(apiUrl)
return res.json()
}
const findMatchingFileSize = (): number | null => {
const matchingFile = modelData.value?.files?.find(
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
)
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
}
const {
state: modelData,
isLoading,
error
} = useAsyncState(fetchModelData, null, {
immediate: true
})
const fileSize = computed(() =>
!isLoading.value ? findMatchingFileSize() : null
)
return {
fileSize,
isLoading,
error,
modelData
}
}

View File

@@ -581,6 +581,15 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.showManagerDialog()
}
}
]
}

View File

@@ -1,16 +1,24 @@
import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)
const fetchFileSize = async (): Promise<number | null> => {
const setFileSize = (size: number) => {
fileSize.value = size
}
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')
if (size) {
return parseInt(size)
setFileSize(parseInt(size))
} else {
console.error('"content-length" header not found')
return null
@@ -33,8 +41,15 @@ export function useDownload(url: string, fileName?: string) {
link.click()
}
onMounted(async () => {
fileSize.value = await fetchFileSize()
onMounted(() => {
if (isCivitaiModelUrl(url)) {
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
whenever(civitaiSize, setFileSize)
// Try falling back to normal fetch if using Civitai API fails
whenever(civitaiErr, fetchFileSize, { once: true })
} else {
fetchFileSize()
}
})
return {

View File

@@ -77,15 +77,6 @@ export const useLitegraphSettings = () => {
}
})
watchEffect(() => {
const reroutesEnabled = settingStore.get('Comfy.RerouteBeta')
const { canvas } = canvasStore
if (canvas) {
canvas.reroutesEnabled = reroutesEnabled
canvas.setDirty(false, true)
}
})
watchEffect(() => {
const maximumFps = settingStore.get('LiteGraph.Canvas.MaximumFps')
const { canvas } = canvasStore

View File

@@ -0,0 +1,93 @@
import { useEventListener, whenever } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { api } from '@/scripts/api'
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
enum ManagerWsQueueStatus {
DONE = 'done',
IN_PROGRESS = 'in_progress'
}
export const useManagerQueue = () => {
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
const clientQueueLength = computed(() => clientQueueItems.value.length)
const nextOnCompleted = ref<(() => void) | undefined>()
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
const cleanupListener = useEventListener(
api,
MANAGER_WS_MSG_TYPE,
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
serverQueueStatus.value = event.detail.status
}
}
)
const startNextTask = () => {
const nextTask = clientQueueItems.value.shift()
if (!nextTask) return
const { task, onComplete } = nextTask
task()
.then(() => {
// Set the task's onComplete to be executed the next time the server is idle
nextOnCompleted.value = onComplete
})
.catch((e) => {
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
console.error(message)
})
}
const enqueueTask = <T>(task: QueuedTask<T>): void => {
clientQueueItems.value.push(task)
}
const clearQueue = () => {
nextOnCompleted.value = undefined
clientQueueItems.value = []
}
const cleanup = () => {
clearQueue()
cleanupListener()
}
whenever(nextTaskReady, startNextTask)
whenever(isServerIdle, () => {
if (nextOnCompleted.value) {
nextOnCompleted.value()
nextOnCompleted.value = undefined
}
})
return {
allTasksDone,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
enqueueTask,
clearQueue,
cleanup
}
}

View File

@@ -7,6 +7,7 @@ import type { components } from '@/types/comfyRegistryTypes'
const SEARCH_DEBOUNCE_TIME = 256
const DEFAULT_PAGE_SIZE = 60
const DEFAULT_SORT_FIELD: keyof components['schemas']['Node'] = 'downloads'
/**
* Composable for managing UI state of Comfy Node Registry search.
@@ -18,6 +19,7 @@ export function useRegistrySearch() {
const searchQuery = ref('')
const pageNumber = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const sortField = ref<keyof components['schemas']['Node']>(DEFAULT_SORT_FIELD)
const searchResults = ref<components['schemas']['Node'][]>([])
const search = async () => {
@@ -26,7 +28,8 @@ export function useRegistrySearch() {
const result = isEmptySearch
? await registryStore.listAllPacks({
page: pageNumber.value,
limit: pageSize.value
limit: pageSize.value,
sort: [sortField.value]
})
: await registryService.search({
search: searchQuery.value,
@@ -45,12 +48,12 @@ export function useRegistrySearch() {
}
}
// Debounce search when query changes
const debouncedSearch = debounce(search, SEARCH_DEBOUNCE_TIME)
// Debounce search when query changes
watch(() => searchQuery.value, debouncedSearch)
// Normal search when page number changes and on load
watch(() => pageNumber.value, search, { immediate: true })
watch(() => [pageNumber.value, sortField.value], search, { immediate: true })
onUnmounted(() => {
debouncedSearch.cancel() // Cancel debounced searches
@@ -61,6 +64,7 @@ export function useRegistrySearch() {
return {
pageNumber,
pageSize,
sortField,
searchQuery,
searchResults,
isLoading: registryService.isLoading,

View File

@@ -1,16 +1,24 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { generateUUID } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
@@ -21,6 +29,71 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: MultiSelectWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions as Record<string | symbol, any>, {
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
@@ -29,48 +102,9 @@ export const useComboWidget = () => {
if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const comboOptions = inputSpec.options ?? []
const defaultValue = getDefaultValue(inputSpec)
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions as Record<string | symbol, any>, {
get(target, prop: string | symbol) {
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
return inputSpec.multi_select
? addMultiSelectWidget(node, inputSpec)
: addComboWidget(node, inputSpec)
}
return widgetConstructor

View File

@@ -0,0 +1,278 @@
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
) => {
const canvas = app.canvas
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = 15
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
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)
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<unknown>
// Dummy value to satisfy type requirements
value: string
y: number = 0
constructor(name: string, options: IWidgetOptions<unknown>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widget_width: number,
y: number,
H: number
): void {
renderPreview(ctx, node, y)
}
computeLayoutSize(this: IBaseWidget, node: LGraphNode) {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(inputSpec.name, {
serialize: false
})
)
}
return widgetConstructor
}

View File

@@ -528,12 +528,12 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.RerouteBeta',
category: ['LiteGraph', 'RerouteBeta'],
name: 'Opt-in to the reroute beta test',
tooltip:
'Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden.',
experimental: true,
tooltip: 'No longer has any effect; reroutes are always enabled.',
deprecated: true,
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.42'
versionAdded: '1.3.42',
versionModified: '1.13.3'
},
{
id: 'Comfy.Graph.LinkMarkers',

View File

@@ -68,11 +68,13 @@ class Load3DConfiguration {
this.load3d.setBackgroundColor(bgColor)
const lightIntensity = this.load3d.loadNodeProperty('Light Intensity', 5)
const lightIntensity: number = Number(
this.load3d.loadNodeProperty('Light Intensity', 5)
)
this.load3d.setLightIntensity(lightIntensity)
const fov = this.load3d.loadNodeProperty('FOV', 35)
const fov: number = Number(this.load3d.loadNodeProperty('FOV', 35))
this.load3d.setFOV(fov)
@@ -113,6 +115,12 @@ class Load3DConfiguration {
this.load3d.setMaterialMode(materialMode)
const edgeThreshold: number = Number(
this.load3d.loadNodeProperty('Edge Threshold', 85)
)
this.load3d.setEdgeThreshold(edgeThreshold)
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -233,6 +233,10 @@ class Load3d {
)
}
setEdgeThreshold(threshold: number): void {
this.modelManager.setEdgeThreshold(threshold)
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)

View File

@@ -161,7 +161,7 @@ export class ModelManager implements ModelManagerInterface {
const mesh = meshes[key]
const parent = mesh.parent
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 10)
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 85)
const line = new THREE.LineSegments(
lineGeom,
@@ -200,6 +200,76 @@ export class ModelManager implements ModelManagerInterface {
})
}
setEdgeThreshold(threshold: number): void {
if (!this.edgesModel || !this.currentModel) {
return
}
const linesToRemove: THREE.Object3D[] = []
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.LineSegments ||
child instanceof LineSegments2
) {
linesToRemove.push(child)
}
})
for (const line of linesToRemove) {
if (line.parent) {
line.parent.remove(line)
}
}
const meshes: THREE.Mesh[] = []
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child)
}
})
for (const mesh of meshes) {
const meshClone = mesh.clone()
let lineGeom = new THREE.EdgesGeometry(meshClone.geometry, threshold)
const line = new THREE.LineSegments(
lineGeom,
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
)
line.position.copy(mesh.position)
line.scale.copy(mesh.scale)
line.rotation.copy(mesh.rotation)
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
lineGeom
)
const thickLines = new LineSegments2(
thickLineGeom,
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
)
thickLines.position.copy(mesh.position)
thickLines.scale.copy(mesh.scale)
thickLines.rotation.copy(mesh.rotation)
this.edgesModel.add(line)
this.edgesModel.add(thickLines)
}
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
this.eventManager.emitEvent('edgeThresholdChange', threshold)
}
disposeBackgroundModel(): void {
if (this.backgroundModel) {
if (this.backgroundModel.parent) {

View File

@@ -1305,6 +1305,7 @@ class PaintBucketTool {
private imageData: ImageData | null = null
private data: Uint8ClampedArray | null = null
private tolerance: number = 5
private fillOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
@@ -1333,6 +1334,11 @@ class PaintBucketTool {
)
this.messageBroker.subscribe('invert', () => this.invertMask())
// Add new listener for opacity setting
this.messageBroker.subscribe('setFillOpacity', (opacity: number) =>
this.setFillOpacity(opacity)
)
}
private addPullTopics() {
@@ -1340,6 +1346,17 @@ class PaintBucketTool {
'getTolerance',
async () => this.tolerance
)
// Add pull topic for fillOpacity
this.messageBroker.createPullTopic(
'getFillOpacity',
async () => (this.fillOpacity / 255) * 100
)
}
// Add method to set opacity
setFillOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.fillOpacity = Math.floor((opacity / 100) * 255)
}
private getPixel(x: number, y: number): number {
@@ -1439,8 +1456,8 @@ class PaintBucketTool {
}
visited[visitedIndex] = 1
// Set alpha to 255 for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? 255 : 0, maskColor)
// Set alpha to fillOpacity for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? this.fillOpacity : 0, maskColor)
// Check neighbors
const checkNeighbor = (nx: number, ny: number) => {
@@ -1540,6 +1557,7 @@ class ColorSelectTool {
private applyWholeImage: boolean = false
private maskBoundry: boolean = false
private maskTolerance: number = 0
private selectOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
@@ -1587,6 +1605,11 @@ class ColorSelectTool {
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
this.setMaskTolerance(maskTolerance)
)
// Add new listener for opacity setting
this.messageBroker.subscribe('setSelectionOpacity', (opacity: number) =>
this.setSelectOpacity(opacity)
)
}
private async addPullTopics() {
@@ -1805,7 +1828,7 @@ class ColorSelectTool {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
this.setPixel(x, y, 255, maskColor)
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
}
}
// Allow UI updates between chunks
@@ -1845,7 +1868,7 @@ class ColorSelectTool {
}
visited[visitedIndex] = 1
this.setPixel(x, y, 255, maskColor)
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
// Inline direction checks for better performance
if (
@@ -1941,6 +1964,18 @@ class ColorSelectTool {
setMaskTolerance(maskTolerance: number): void {
this.maskTolerance = maskTolerance
}
// Add method to set opacity
setSelectOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.selectOpacity = Math.floor((opacity / 100) * 255)
// Update preview if applicable
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
}
class BrushTool {
@@ -3011,10 +3046,25 @@ class UIManager {
}
)
// Add new slider for fill opacity
const fillOpacity = (await this.messageBroker.pull('getFillOpacity')) || 100
const fillOpacitySliderObj = this.createSlider(
'Fill Opacity',
0,
100,
1,
fillOpacity,
(event, value) => {
this.messageBroker.publish('setFillOpacity', parseInt(value))
}
)
paint_bucket_settings_container.appendChild(paint_bucket_settings_title)
paint_bucket_settings_container.appendChild(
paintBucketToleranceSliderObj.container
)
// Add the new opacity slider to the UI
paint_bucket_settings_container.appendChild(fillOpacitySliderObj.container)
return paint_bucket_settings_container
}
@@ -3038,6 +3088,18 @@ class UIManager {
}
)
// Add new slider for selection opacity
const selectionOpacitySliderObj = this.createSlider(
'Selection Opacity',
0,
100,
1,
100, // Default to 100%
(event, value) => {
this.messageBroker.publish('setSelectionOpacity', parseInt(value))
}
)
const livePreviewToggle = this.createToggle(
'Live Preview',
(event, value) => {
@@ -3083,6 +3145,10 @@ class UIManager {
color_select_settings_container.appendChild(
colorSelectToleranceSliderObj.container
)
// Add the new opacity slider to the UI
color_select_settings_container.appendChild(
selectionOpacitySliderObj.container
)
color_select_settings_container.appendChild(livePreviewToggle)
color_select_settings_container.appendChild(wholeImageToggle)
color_select_settings_container.appendChild(methodSelect)
@@ -4753,6 +4819,8 @@ class MessageBroker {
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
this.createPushTopic('setSelectionOpacity')
this.createPushTopic('setFillOpacity')
}
/**

View File

@@ -88,9 +88,6 @@ app.registerExtension({
img.onload = () => {
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
node.setSizeForImage?.()
})
}
img.src = data
}

View File

@@ -628,29 +628,27 @@ app.registerExtension({
return input?.type
}
document.addEventListener(
'litegraph:canvas',
async (e: LiteGraphCanvasEvent) => {
if (e.detail.subType === 'connectingWidgetLink') {
const { node, link, widget } = e.detail
if (!node || !link || !widget) return
app.canvas.linkConnector.events.addEventListener(
'dropped-on-widget',
(e) => {
const { node, link, widget } = e.detail
if (!node || !link || !widget) return
const nodeData = node.constructor.nodeData
if (!nodeData) return
const all = {
...nodeData?.input?.required,
...nodeData?.input?.optional
}
const inputSpec = all[widget.name]
if (!inputSpec) return
const input = convertToInput(node, widget, inputSpec)
if (!input) return
const originNode = link.node
originNode.connect(link.slot, node, node.inputs.lastIndexOf(input))
const nodeData = node.constructor.nodeData
if (!nodeData) return
const all = {
...nodeData?.input?.required,
...nodeData?.input?.optional
}
const inputSpec = all[widget.name]
if (!inputSpec) return
const input = convertToInput(node, widget, inputSpec)
if (!input) return
const originNode = link.node
originNode.connectSlots(link.fromSlot, node, input, undefined)
}
)
},

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -78,7 +78,60 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"capture": "capture"
"capture": "capture",
"nodes": "Nodes",
"community": "Community",
"all": "All",
"updated": "Updated",
"resultsCount": "Found {count} Results",
"status": "Status",
"description": "Description",
"name": "Name",
"category": "Category",
"sort": "Sort",
"filter": "Filter",
"apply": "Apply"
},
"manager": {
"title": "Custom Nodes Manager",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
"repository": "Repository",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
"createdBy": "Created By",
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
"errorConnecting": "Error connecting to the Comfy Node Registry.",
"noResultsFound": "No results found matching your search.",
"tryDifferentSearch": "Please try a different search query.",
"tryAgainLater": "Please try again later.",
"nodePack": "Node Pack",
"searchPlaceholder": "Search",
"version": "Version",
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
"pending": "Pending",
"flagged": "Flagged",
"deleted": "Deleted",
"banned": "Banned",
"unknown": "Unknown"
},
"sort": {
"rating": "Rating",
"downloads": "Most Popular"
},
"filter": {
"nodePack": "Node Pack",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
@@ -369,6 +422,20 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
"Basics": "Basics",
"Flux": "Flux",
"ControlNet": "ControlNet",
"Upscaling": "Upscaling",
"Video": "Video",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Area Composition": "Area Composition",
"3D": "3D",
"Audio": "Audio"
},
"template": {
"Flux": {
"flux_dev_checkpoint_example": "Flux Dev",
@@ -390,7 +457,7 @@
"inpain_model_outpainting": "Outpaint"
},
"ControlNet": {
"controlnet_example": "ControlNet",
"controlnet_example": "Scribble ControlNet",
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
@@ -512,6 +579,7 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",
@@ -858,6 +926,7 @@
"model": "Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode..."
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold"
}
}

View File

@@ -245,7 +245,7 @@
},
"Comfy_RerouteBeta": {
"name": "Opt-in to the reroute beta test",
"tooltip": "Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden."
"tooltip": "No longer has any effect; reroutes are always enabled."
},
"Comfy_Sidebar_Location": {
"name": "Sidebar location",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Charger le flux de travail par défaut"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -116,14 +116,18 @@
"g": {
"about": "À propos",
"add": "Ajouter",
"all": "Tout",
"apply": "Appliquer",
"back": "Retour",
"cancel": "Annuler",
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"community": "Communauté",
"confirm": "Confirmer",
"continue": "Continuer",
"control_after_generate": "contrôle après génération",
@@ -134,6 +138,7 @@
"customizeFolder": "Personnaliser le dossier",
"delete": "Supprimer",
"deprecated": "DEPR",
"description": "Description",
"devices": "Appareils",
"disableAll": "Désactiver tout",
"download": "Télécharger",
@@ -144,6 +149,7 @@
"export": "Exportation",
"extensionName": "Nom de l'extension",
"feedback": "Commentaires",
"filter": "Filtrer",
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"goToNode": "Aller au nœud",
@@ -157,6 +163,7 @@
"loadWorkflow": "Charger le flux de travail",
"loading": "Chargement",
"logs": "Journaux",
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",
"no": "Non",
@@ -164,6 +171,7 @@
"noTasksFound": "Aucune tâche trouvée",
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
"noWorkflowsFound": "Aucun flux de travail trouvé.",
"nodes": "Nœuds",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",
@@ -177,6 +185,7 @@
"reportSent": "Rapport soumis",
"reset": "Réinitialiser",
"resetKeybindingsTooltip": "Réinitialiser les raccourcis clavier par défaut",
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"searchExtensions": "Rechercher des extensions",
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
@@ -187,9 +196,12 @@
"searchWorkflows": "Rechercher des flux de travail",
"settings": "Paramètres",
"showReport": "Afficher le rapport",
"sort": "Trier",
"status": "Statut",
"success": "Succès",
"systemInfo": "Informations système",
"terminal": "Terminal",
"updated": "Mis à jour",
"upload": "Téléverser",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
@@ -324,6 +336,7 @@
"load3d": {
"backgroundColor": "Couleur de fond",
"camera": "Caméra",
"edgeThreshold": "Seuil de Bordure",
"fov": "FOV",
"light": "Lumière",
"lightIntensity": "Intensité de la lumière",
@@ -359,6 +372,47 @@
"terminalDefaultMessage": "Lorsque vous exécutez une commande de dépannage, toute sortie sera affichée ici.",
"title": "Maintenance"
},
"manager": {
"createdBy": "Créé par",
"discoverCommunityContent": "Découvrez les packs de nœuds, extensions et plus encore créés par la communauté...",
"downloads": "Téléchargements",
"errorConnecting": "Erreur de connexion au registre de nœuds Comfy.",
"filter": {
"disabled": "Désactivé",
"enabled": "Activé",
"nodePack": "Pack de Nœuds"
},
"installSelected": "Installer sélectionné",
"lastUpdated": "Dernière mise à jour",
"latestVersion": "Dernière",
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"sort": {
"downloads": "Le plus populaire",
"rating": "Évaluation"
},
"status": {
"active": "Actif",
"banned": "Banni",
"deleted": "Supprimé",
"flagged": "Signalé",
"pending": "En attente",
"unknown": "Inconnu"
},
"title": "Gestionnaire de Nœuds Personnalisés",
"totalNodes": "Total de Nœuds",
"tryAgainLater": "Veuillez réessayer plus tard.",
"tryDifferentSearch": "Veuillez essayer une autre requête de recherche.",
"version": "Version"
},
"menu": {
"autoQueue": "File d'attente automatique",
"batchCount": "Nombre de lots",
@@ -398,6 +452,7 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -776,6 +831,20 @@
"removeFromBookmarks": "Retirer des Favoris"
},
"templateWorkflows": {
"category": {
"3D": "3D",
"Area Composition": "Composition de zone",
"Audio": "Audio",
"Basics": "Basiques",
"ComfyUI Examples": "Exemples ComfyUI",
"ControlNet": "ControlNet",
"Custom Nodes": "Nœuds personnalisés",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Upscaling": "Mise à l'échelle",
"Video": "Vidéo"
},
"template": {
"3D": {
"stable_zero123_example": "Stable Zero123"

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "デフォルトのワークフローを読み込む"
},
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -116,14 +116,18 @@
"g": {
"about": "情報",
"add": "追加",
"all": "すべて",
"apply": "適用する",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
"command": "コマンド",
"community": "コミュニティ",
"confirm": "確認",
"continue": "続ける",
"control_after_generate": "生成後の制御",
@@ -134,6 +138,7 @@
"customizeFolder": "フォルダーをカスタマイズ",
"delete": "削除",
"deprecated": "非推奨",
"description": "説明",
"devices": "デバイス",
"disableAll": "すべて無効にする",
"download": "ダウンロード",
@@ -144,6 +149,7 @@
"export": "エクスポート",
"extensionName": "拡張機能名",
"feedback": "フィードバック",
"filter": "フィルタ",
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
@@ -157,6 +163,7 @@
"loadWorkflow": "ワークフローを読み込む",
"loading": "読み込み中",
"logs": "ログ",
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",
"no": "いいえ",
@@ -164,6 +171,7 @@
"noTasksFound": "タスクが見つかりません",
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",
@@ -177,6 +185,7 @@
"reportSent": "レポートが送信されました",
"reset": "リセット",
"resetKeybindingsTooltip": "キーバインディングをデフォルトにリセット",
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"searchExtensions": "拡張機能を検索",
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
@@ -187,9 +196,12 @@
"searchWorkflows": "ワークフローを検索",
"settings": "設定",
"showReport": "レポートを表示",
"sort": "並び替え",
"status": "ステータス",
"success": "成功",
"systemInfo": "システム情報",
"terminal": "ターミナル",
"updated": "更新済み",
"upload": "アップロード",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
@@ -324,6 +336,7 @@
"load3d": {
"backgroundColor": "背景色",
"camera": "カメラ",
"edgeThreshold": "エッジ閾値",
"fov": "FOV",
"light": "ライト",
"lightIntensity": "光の強度",
@@ -359,6 +372,47 @@
"terminalDefaultMessage": "トラブルシューティングコマンドを実行すると、出力はここに表示されます。",
"title": "メンテナンス"
},
"manager": {
"createdBy": "作成者",
"discoverCommunityContent": "コミュニティが作成したノードパック、拡張機能などを探す...",
"downloads": "ダウンロード",
"errorConnecting": "Comfy Node Registryへの接続エラー。",
"filter": {
"disabled": "無効",
"enabled": "有効",
"nodePack": "ノードパック"
},
"installSelected": "選択したものをインストール",
"lastUpdated": "最終更新日",
"latestVersion": "最新",
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"sort": {
"downloads": "最も人気",
"rating": "評価"
},
"status": {
"active": "アクティブ",
"banned": "禁止",
"deleted": "削除済み",
"flagged": "フラグ付き",
"pending": "保留中",
"unknown": "不明"
},
"title": "カスタムノードマネージャ",
"totalNodes": "合計ノード数",
"tryAgainLater": "後ほど再試行してください。",
"tryDifferentSearch": "別の検索クエリを試してみてください。",
"version": "バージョン"
},
"menu": {
"autoQueue": "自動キュー",
"batchCount": "バッチ数",
@@ -398,6 +452,7 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -776,6 +831,20 @@
"removeFromBookmarks": "ブックマークから削除"
},
"templateWorkflows": {
"category": {
"3D": "3D",
"Area Composition": "エリア構成",
"Audio": "オーディオ",
"Basics": "基本",
"ComfyUI Examples": "ComfyUIの例",
"ControlNet": "ControlNet",
"Custom Nodes": "カスタムノード",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Upscaling": "アップスケーリング",
"Video": "ビデオ"
},
"template": {
"3D": {
"stable_zero123_example": "Stable Zero123"

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -116,14 +116,18 @@
"g": {
"about": "정보",
"add": "추가",
"all": "모두",
"apply": "적용",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"community": "커뮤니티",
"confirm": "확인",
"continue": "계속",
"control_after_generate": "생성 후 제어",
@@ -134,6 +138,7 @@
"customizeFolder": "폴더 사용자 정의",
"delete": "삭제",
"deprecated": "사용 중단",
"description": "설명",
"devices": "장치",
"disableAll": "모두 비활성화",
"download": "다운로드",
@@ -144,6 +149,7 @@
"export": "내보내기",
"extensionName": "확장 이름",
"feedback": "피드백",
"filter": "필터",
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
@@ -157,6 +163,7 @@
"loadWorkflow": "워크플로 로드",
"loading": "로딩 중",
"logs": "로그",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
"no": "아니오",
@@ -164,6 +171,7 @@
"noTasksFound": "작업을 찾을 수 없습니다.",
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",
@@ -177,6 +185,7 @@
"reportSent": "보고서 제출됨",
"reset": "재설정",
"resetKeybindingsTooltip": "키 바인딩을 기본값으로 재설정",
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"searchExtensions": "확장 검색",
"searchFailedMessage": "검색어와 일치하는 설정을 찾을 수 없습니다. 검색어를 조정해 보세요.",
@@ -187,9 +196,12 @@
"searchWorkflows": "워크플로 검색",
"settings": "설정",
"showReport": "보고서 보기",
"sort": "정렬",
"status": "상태",
"success": "성공",
"systemInfo": "시스템 정보",
"terminal": "터미널",
"updated": "업데이트됨",
"upload": "업로드",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로"
@@ -324,6 +336,7 @@
"load3d": {
"backgroundColor": "배경색",
"camera": "카메라",
"edgeThreshold": "엣지 임계값",
"fov": "FOV",
"light": "빛",
"lightIntensity": "조명 강도",
@@ -359,6 +372,47 @@
"terminalDefaultMessage": "문제 해결 명령을 실행하면 출력이 여기에 표시됩니다.",
"title": "유지 보수"
},
"manager": {
"createdBy": "작성자",
"discoverCommunityContent": "커뮤니티에서 만든 노드 팩, 확장 프로그램 등을 찾아보세요...",
"downloads": "다운로드",
"errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.",
"filter": {
"disabled": "비활성화",
"enabled": "활성화",
"nodePack": "노드 팩"
},
"installSelected": "선택한 항목 설치",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "야간",
"noDescription": "설명이 없습니다",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"packsSelected": "선택한 팩",
"repository": "저장소",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"sort": {
"downloads": "가장 인기 있는",
"rating": "평점"
},
"status": {
"active": "활성",
"banned": "금지됨",
"deleted": "삭제됨",
"flagged": "플래그 표시됨",
"pending": "대기 중",
"unknown": "알 수 없음"
},
"title": "사용자 정의 노드 관리자",
"totalNodes": "총 노드",
"tryAgainLater": "나중에 다시 시도해 주세요.",
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
"version": "버전"
},
"menu": {
"autoQueue": "자동 실행 큐",
"batchCount": "배치 수",
@@ -398,6 +452,7 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -776,6 +831,20 @@
"removeFromBookmarks": "북마크에서 제거"
},
"templateWorkflows": {
"category": {
"3D": "3D",
"Area Composition": "영역 구성",
"Audio": "오디오",
"Basics": "기본",
"ComfyUI Examples": "ComfyUI 예시",
"ControlNet": "ControlNet",
"Custom Nodes": "사용자 정의 노드",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Upscaling": "업스케일링",
"Video": "비디오"
},
"template": {
"3D": {
"stable_zero123_example": "스테이블 제로123"

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Загрузить стандартный рабочий процесс"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -116,14 +116,18 @@
"g": {
"about": "О программе",
"add": "Добавить",
"all": "Все",
"apply": "Применить",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
"command": "Команда",
"community": "Сообщество",
"confirm": "Подтвердить",
"continue": "Продолжить",
"control_after_generate": "управление после генерации",
@@ -134,6 +138,7 @@
"customizeFolder": "Настроить папку",
"delete": "Удалить",
"deprecated": "Устарело",
"description": "Описание",
"devices": "Устройства",
"disableAll": "Отключить все",
"download": "Скачать",
@@ -144,6 +149,7 @@
"export": "Экспорт",
"extensionName": "Название расширения",
"feedback": "Обратная связь",
"filter": "Фильтр",
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
@@ -157,6 +163,7 @@
"loadWorkflow": "Загрузить рабочий процесс",
"loading": "Загрузка",
"logs": "Логи",
"name": "Имя",
"newFolder": "Новая папка",
"next": "Далее",
"no": "Нет",
@@ -164,6 +171,7 @@
"noTasksFound": "Задачи не найдены",
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",
@@ -177,6 +185,7 @@
"reportSent": "Отчёт отправлен",
"reset": "Сбросить",
"resetKeybindingsTooltip": "Сбросить сочетания клавиш по умолчанию",
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"searchExtensions": "Поиск расширений",
"searchFailedMessage": "Мы не смогли найти настройки, соответствующие вашему запросу. Попробуйте изменить поисковые термины.",
@@ -187,9 +196,12 @@
"searchWorkflows": "Поиск рабочих процессов",
"settings": "Настройки",
"showReport": "Показать отчёт",
"sort": "Сортировать",
"status": "Статус",
"success": "Успех",
"systemInfo": "Информация о системе",
"terminal": "Терминал",
"updated": "Обновлено",
"upload": "Загрузить",
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
@@ -324,6 +336,7 @@
"load3d": {
"backgroundColor": "Цвет фона",
"camera": "Камера",
"edgeThreshold": "Пороговое значение края",
"fov": "Угол обзора",
"light": "Свет",
"lightIntensity": "Интенсивность света",
@@ -359,6 +372,47 @@
"terminalDefaultMessage": "Когда вы запускаете команду для устранения неполадок, любой вывод будет отображаться здесь.",
"title": "Обслуживание"
},
"manager": {
"createdBy": "Создано",
"discoverCommunityContent": "Откройте для себя пакеты узлов, расширения и многое другое, созданные сообществом...",
"downloads": "Загрузки",
"errorConnecting": "Ошибка подключения к реестру Comfy Node.",
"filter": {
"disabled": "Отключено",
"enabled": "Включено",
"nodePack": "Пакет Узлов"
},
"installSelected": "Установить выбранное",
"lastUpdated": "Последнее обновление",
"latestVersion": "Последняя",
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"nightlyVersion": "Ночная",
"noDescription": "Описание отсутствует",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"sort": {
"downloads": "Самые популярные",
"rating": "Рейтинг"
},
"status": {
"active": "Активный",
"banned": "Заблокировано",
"deleted": "Удалено",
"flagged": "Отмечено",
"pending": "В ожидании",
"unknown": "Неизвестно"
},
"title": "Менеджер Пользовательских Узлов",
"totalNodes": "Всего Узлов",
"tryAgainLater": "Пожалуйста, попробуйте позже.",
"tryDifferentSearch": "Пожалуйста, попробуйте изменить запрос.",
"version": "Версия"
},
"menu": {
"autoQueue": "Автоочередь",
"batchCount": "Количество пакетов",
@@ -398,6 +452,7 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -776,6 +831,20 @@
"removeFromBookmarks": "Удалить из закладок"
},
"templateWorkflows": {
"category": {
"3D": "3D",
"Area Composition": "Композиция области",
"Audio": "Аудио",
"Basics": "Основы",
"ComfyUI Examples": "Примеры ComfyUI",
"ControlNet": "ControlNet",
"Custom Nodes": "Пользовательские узлы",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Upscaling": "Увеличение разрешения",
"Video": "Видео"
},
"template": {
"3D": {
"stable_zero123_example": "Stable Zero123"

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "加载默认工作流"
},
"Comfy_Manager_CustomNodesManager": {
"label": "自定义节点管理器"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -116,14 +116,18 @@
"g": {
"about": "关于",
"add": "添加",
"all": "全部",
"apply": "应用",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
"command": "指令",
"community": "社区",
"confirm": "确认",
"continue": "继续",
"control_after_generate": "生成后控制",
@@ -134,6 +138,7 @@
"customizeFolder": "自定义文件夹",
"delete": "删除",
"deprecated": "已弃用",
"description": "描述",
"devices": "设备",
"disableAll": "禁用全部",
"download": "下载",
@@ -144,6 +149,7 @@
"export": "导出",
"extensionName": "扩展名称",
"feedback": "反馈",
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"goToNode": "转到节点",
@@ -157,6 +163,7 @@
"loadWorkflow": "加载工作流",
"loading": "加载中",
"logs": "日志",
"name": "名称",
"newFolder": "新文件夹",
"next": "下一个",
"no": "否",
@@ -164,6 +171,7 @@
"noTasksFound": "未找到任务",
"noTasksFoundMessage": "队列中没有任务。",
"noWorkflowsFound": "未找到工作流。",
"nodes": "节点",
"ok": "确定",
"openNewIssue": "打开新问题",
"overwrite": "覆盖",
@@ -177,6 +185,7 @@
"reportSent": "报告已提交",
"reset": "重置",
"resetKeybindingsTooltip": "将快捷键重置为默认",
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"searchExtensions": "搜索扩展",
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
@@ -187,9 +196,12 @@
"searchWorkflows": "搜索工作流",
"settings": "设置",
"showReport": "显示报告",
"sort": "排序",
"status": "状态",
"success": "成功",
"systemInfo": "系统信息",
"terminal": "终端",
"updated": "已更新",
"upload": "上传",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
@@ -324,6 +336,7 @@
"load3d": {
"backgroundColor": "背景颜色",
"camera": "相机",
"edgeThreshold": "边缘阈值",
"fov": "视场",
"light": "灯光",
"lightIntensity": "光照强度",
@@ -359,6 +372,47 @@
"terminalDefaultMessage": "当你运行一个故障排除命令时,任何输出都会在这里显示。",
"title": "维护"
},
"manager": {
"createdBy": "创建者",
"discoverCommunityContent": "发现社区制作的节点包,扩展等等...",
"downloads": "下载",
"errorConnecting": "连接到Comfy节点注册表时出错。",
"filter": {
"disabled": "已禁用",
"enabled": "已启用",
"nodePack": "节点包"
},
"installSelected": "安装选定",
"lastUpdated": "最后更新",
"latestVersion": "最新",
"license": "许可证",
"loadingVersions": "正在加载版本...",
"nightlyVersion": "每夜",
"noDescription": "无可用描述",
"noResultsFound": "未找到符合您搜索的结果。",
"nodePack": "节点包",
"packsSelected": "选定的包",
"repository": "仓库",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"sort": {
"downloads": "最受欢迎",
"rating": "评级"
},
"status": {
"active": "活跃",
"banned": "已禁止",
"deleted": "已删除",
"flagged": "已标记",
"pending": "待定",
"unknown": "未知"
},
"title": "自定义节点管理器",
"totalNodes": "节点总数",
"tryAgainLater": "请稍后再试。",
"tryDifferentSearch": "请尝试不同的搜索查询。",
"version": "版本"
},
"menu": {
"autoQueue": "自动执行",
"batchCount": "批次数量",
@@ -398,6 +452,7 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -776,6 +831,20 @@
"removeFromBookmarks": "从书签中移除"
},
"templateWorkflows": {
"category": {
"3D": "3D",
"Area Composition": "区域组成",
"Audio": "音频",
"Basics": "基础",
"ComfyUI Examples": "ComfyUI示例",
"ControlNet": "ControlNet",
"Custom Nodes": "自定义节点",
"Flux": "Flux",
"SD3_5": "SD3.5",
"SDXL": "SDXL",
"Upscaling": "放大",
"Video": "视频"
},
"template": {
"3D": {
"stable_zero123_example": "稳定Zero123"

View File

@@ -12,11 +12,14 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
})
export const zBaseInputOptions = z
.object({
default: z.any().optional(),
/** @deprecated Group node uses this field. Remove when group node feature is removed. */
defaultInput: z.boolean().optional(),
forceInput: z.boolean().optional(),
tooltip: z.string().optional(),
@@ -72,7 +75,9 @@ export const zComboInputOptions = zBaseInputOptions.extend({
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional()
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])

View File

@@ -7,12 +7,13 @@ import {
LiteGraph,
strokeShape
} from '@comfyorg/litegraph'
import type { Rect, Vector2 } from '@comfyorg/litegraph'
import type { IWidget, Rect, Vector2 } from '@comfyorg/litegraph'
import _ from 'lodash'
import type { ToastMessageOptions } from 'primevue/toast'
import { reactive } from 'vue'
import { st } from '@/i18n'
import type { ResultItem } from '@/schemas/apiSchema'
import {
type ComfyWorkflowJSON,
type ModelFile,
@@ -77,7 +78,7 @@ function sanitizeNodeName(string) {
}
type Clipspace = {
widgets?: { type?: string; name?: string; value?: any }[] | null
widgets?: Pick<IWidget, 'type' | 'name' | 'value'>[] | null
imgs?: HTMLImageElement[] | null
original_imgs?: HTMLImageElement[] | null
images?: any[] | null
@@ -301,7 +302,7 @@ export class ComfyApp {
}
}
static pasteFromClipspace(node) {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
if (ComfyApp.clipspace.imgs && node.imgs) {
@@ -344,6 +345,7 @@ export class ComfyApp {
const index = node.widgets.findIndex((obj) => obj.name === 'image')
if (index >= 0) {
if (
// @ts-expect-error custom widget type
node.widgets[index].type != 'image' &&
typeof node.widgets[index].value == 'string' &&
clip_image.filename
@@ -360,28 +362,24 @@ export class ComfyApp {
if (ComfyApp.clipspace.widgets) {
ComfyApp.clipspace.widgets.forEach(({ type, name, value }) => {
const prop = Object.values(node.widgets).find(
// @ts-expect-errorg
(obj) => obj.type === type && obj.name === name
)
// @ts-expect-error
if (prop && prop.type != 'button') {
if (
// @ts-expect-error
// @ts-expect-error Custom widget type
prop.type != 'image' &&
// @ts-expect-error
typeof prop.value == 'string' &&
// @ts-expect-error Custom widget value
value.filename
) {
// @ts-expect-error
const resultItem = value as ResultItem
prop.value =
(value.subfolder ? value.subfolder + '/' : '') +
value.filename +
(value.type ? ` [${value.type}]` : '')
(resultItem.subfolder ? resultItem.subfolder + '/' : '') +
resultItem.filename +
(resultItem.type ? ` [${resultItem.type}]` : '')
} else {
// @ts-expect-error
prop.value = value
// @ts-expect-error
prop.callback(value)
prop.callback?.(value)
}
}
})
@@ -1166,10 +1164,10 @@ export class ComfyApp {
) {
if (widget.name == 'control_after_generate') {
if (widget.value === true) {
// @ts-expect-error change widget type from boolean to string
// @ts-expect-error string is not assignable to boolean
widget.value = 'randomize'
} else if (widget.value === false) {
// @ts-expect-error change widget type from boolean to string
// @ts-expect-error string is not assignable to boolean
widget.value = 'fixed'
}
}

View File

@@ -1,56 +1,68 @@
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
import { LGraphNode } from '@comfyorg/litegraph'
import type {
ICustomWidget,
IWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
import { type Component, toRaw } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { app } from '@/scripts/app'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { generateRandomSuffix } from '@/utils/formatUtil'
import { generateUUID } from '@/utils/formatUtil'
interface Rect {
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
export interface BaseDOMWidget<V extends object | string>
extends ICustomWidget {
// ICustomWidget properties
type: 'custom'
element: T
options: DOMWidgetOptions<T, V>
options: DOMWidgetOptions<V>
value: V
callback?: (value: V) => void
// BaseDOMWidget properties
/** The unique ID of the widget. */
readonly id: string
/** The node that the widget belongs to. */
readonly node: LGraphNode
/** Whether the widget is visible. */
isVisible(): boolean
}
/**
* A DOM widget that wraps a custom HTML element as a litegraph widget.
*/
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends BaseDOMWidget<V> {
element: T
/**
* @deprecated Legacy property used by some extensions for customtext
* (textarea) widgets. Use `element` instead as it provides the same
* (textarea) widgets. Use {@link element} instead as it provides the same
* functionality and works for all DOMWidget types.
*/
inputEl?: T
callback?: (value: V) => void
// DOMWidget properties
/** The unique ID of the widget. */
id: string
}
export interface DOMWidgetOptions<
T extends HTMLElement,
V extends object | string
> extends IWidgetOptions {
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<V extends object | string>
extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
}
export interface DOMWidgetOptions<V extends object | string>
extends IWidgetOptions {
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: DOMWidget<T, V>) => void
onHide?: (widget: BaseDOMWidget<V>) => void
getValue?: () => V
setValue?: (value: V) => void
getMinHeight?: () => number
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: DOMWidget<T, V>) => void
onDraw?: (widget: BaseDOMWidget<V>) => void
/**
* @deprecated Use `afterResize` instead. This callback is a legacy API
* that fires before resize happens, but it is no longer supported. Now it
@@ -58,132 +70,45 @@ export interface DOMWidgetOptions<
* The resize logic has been upstreamed to litegraph in
* https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
*/
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
beforeResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
afterResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
widget: IWidget
): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
function getClipPath(
node: LGraphNode,
element: HTMLElement,
canvasRect: DOMRect
): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes ?? {}
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect()
const MARGIN = 4
const { offset, scale } = app.canvas.ds
const { renderArea } = selectedNode
export const isComponentWidget = <V extends object | string>(
widget: IWidget
): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
// Get intersection in browser space
const intersection = intersect(
{
x: elRect.left - canvasRect.left,
y: elRect.top - canvasRect.top,
width: elRect.width,
height: elRect.height
},
{
x: (renderArea[0] + offset[0] - MARGIN) * scale,
y: (renderArea[1] + offset[1] - MARGIN) * scale,
width: (renderArea[2] + 2 * MARGIN) * scale,
height: (renderArea[3] + 2 * MARGIN) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return ''
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set<LGraphNode>()
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
LGraphCanvas.prototype.computeVisibleNodes = function (
nodes?: LGraphNode[],
out?: LGraphNode[]
): LGraphNode[] {
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets ?? []) {
if (w.element) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || node.collapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
}
}
}
}
}
return visibleNodes
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
implements DOMWidget<T, V>
abstract class BaseDOMWidgetImpl<V extends object | string>
implements BaseDOMWidget<V>
{
readonly type: 'custom'
readonly name: string
readonly element: T
readonly options: DOMWidgetOptions<T, V>
readonly options: DOMWidgetOptions<V>
computedHeight?: number
y: number = 0
callback?: (value: V) => void
readonly id: string
mouseDownHandler?: (event: MouseEvent) => void
readonly node: LGraphNode
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
element: T
options: DOMWidgetOptions<T, V>
options: DOMWidgetOptions<V>
}) {
// @ts-expect-error custom widget type
this.type = obj.type
this.name = obj.name
this.element = obj.element
this.options = obj.options
this.id = obj.id
if (this.element.blur) {
this.mouseDownHandler = (event) => {
if (!this.element.contains(event.target as HTMLElement)) {
this.element.blur()
}
}
document.addEventListener('mousedown', this.mouseDownHandler)
}
this.node = obj.node
}
get value(): V {
@@ -195,6 +120,42 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.callback?.(this.value)
}
isVisible(): boolean {
return (
!_.isNil(this.computedHeight) &&
this.computedHeight > 0 &&
!['converted-widget', 'hidden'].includes(this.type) &&
!this.node.collapsed
)
}
draw(): void {
this.options.onDraw?.(this)
}
onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
extends BaseDOMWidgetImpl<V>
implements DOMWidget<T, V>
{
readonly element: T
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
element: T
options: DOMWidgetOptions<V>
}) {
super(obj)
this.element = obj.element
}
/** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) {
// @ts-expect-error custom widget type
@@ -237,70 +198,61 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
minWidth: 0
}
}
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number
): void {
const { offset, scale } = app.canvas.ds
const hidden =
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
(this.computedHeight ?? 0) <= 0 ||
// @ts-expect-error custom widget type
this.type === 'converted-widget' ||
// @ts-expect-error custom widget type
this.type === 'hidden' ||
node.collapsed
export class ComponentWidgetImpl<V extends object | string>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V>
{
readonly component: Component
readonly inputSpec: InputSpec
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
const actualHidden = hidden || !isInVisibleNodes
const wasHidden = this.element.hidden
this.element.hidden = actualHidden
this.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
this.options.onHide?.(this)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(this.element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
constructor(obj: {
id: string
node: LGraphNode
name: string
component: Component
inputSpec: InputSpec
options: DOMWidgetOptions<V>
}) {
super({
...obj,
type: 'custom'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
const clipPath = getClipPath(node, this.element, elRect)
this.element.style.clipPath = clipPath ?? 'none'
this.element.style.willChange = 'clip-path'
}
this.options.onDraw?.(this)
this.component = obj.component
this.inputSpec = obj.inputSpec
}
onRemove(): void {
if (this.mouseDownHandler) {
document.removeEventListener('mousedown', this.mouseDownHandler)
computeLayoutSize() {
const minHeight = this.options.getMinHeight?.() ?? 50
const maxHeight = this.options.getMaxHeight?.()
return {
minHeight,
maxHeight,
minWidth: 0
}
this.element.remove()
useDomWidgetStore().unregisterWidget(this.id)
}
serializeValue(): V {
return toRaw(this.value)
}
}
export const addWidget = <W extends BaseDOMWidget<object | string>>(
node: LGraphNode,
widget: W
) => {
node.addCustomWidget(widget)
node.onRemoved = useChainCallback(node.onRemoved, () => {
widget.onRemove?.()
})
node.onResize = useChainCallback(node.onResize, () => {
widget.options.beforeResize?.call(widget, node)
widget.options.afterResize?.call(widget, node)
})
useDomWidgetStore().registerWidget(widget)
}
LGraphNode.prototype.addDOMWidget = function <
@@ -311,27 +263,18 @@ LGraphNode.prototype.addDOMWidget = function <
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
element.hidden = true
element.style.display = 'none'
const { nodeData } = this.constructor
const tooltip = (nodeData?.input.required?.[name] ??
nodeData?.input.optional?.[name])?.[1]?.tooltip
if (tooltip && !element.title) {
element.title = tooltip
}
const widget = new DOMWidgetImpl({
id: `${this.id}:${name}:${generateRandomSuffix()}`,
id: generateUUID(),
node: this,
name,
type,
element,
options
options: { hideOnZoom: true, ...options }
})
// Note: Before `LGraphNode.configure` is called, `this.id` is always `-1`.
addWidget(this, widget as unknown as BaseDOMWidget<object | string>)
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
// Some custom nodes are explicitly expecting getter and setter of `value`
@@ -346,31 +289,5 @@ LGraphNode.prototype.addDOMWidget = function <
}
})
// Ensure selectOn exists before iteration
const selectEvents = options.selectOn ?? ['focus', 'click']
for (const evt of selectEvents) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget)
elementWidgets.add(this)
const onRemoved = this.onRemoved
this.onRemoved = function (this: LGraphNode) {
widget.onRemove()
elementWidgets.delete(this)
onRemoved?.call(this)
}
this.onResize = useChainCallback(this.onResize, () => {
options.beforeResize?.call(widget, this)
options.afterResize?.call(widget, this)
})
useDomWidgetStore().registerWidget(widget)
return widget
}

View File

@@ -2,7 +2,17 @@
import { app } from '../app'
import { $el } from '../ui'
export function calculateImageGrid(imgs, dw, dh) {
export function calculateImageGrid(
imgs,
dw,
dh
): {
cellWidth: number
cellHeight: number
cols: number
rows: number
shiftX: number
} {
let best = 0
let w = imgs[0].naturalWidth
let h = imgs[0].naturalHeight

View File

@@ -222,11 +222,11 @@ export function addValueControlWidgets(
}
} else {
//number
let { min = 0, max = 1, step = 1 } = targetWidget.options
let { min = 0, max = 1, step2 = 1 } = targetWidget.options
// limit to something that javascript can handle
max = Math.min(1125899906842624, max)
min = Math.max(-1125899906842624, min)
let range = (max - min) / (step / 10)
let range = (max - min) / step2
//adjust values based on valueControl Behaviour
switch (v) {
@@ -234,15 +234,14 @@ export function addValueControlWidgets(
break
case 'increment':
// @ts-expect-error targetWidget.value can be number or string
targetWidget.value += step / 10
targetWidget.value += step2
break
case 'decrement':
// @ts-expect-error targetWidget.value can be number or string
targetWidget.value -= step / 10
targetWidget.value -= step2
break
case 'randomize':
targetWidget.value =
Math.floor(Math.random() * range) * (step / 10) + min
targetWidget.value = Math.floor(Math.random() * range) * step2 + min
break
default:
break

View File

@@ -0,0 +1,273 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import {
type InstallPackParams,
type InstalledPacksResponse,
type ManagerPackInfo,
type ManagerQueueStatus,
SelectedVersion,
type UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const GENERIC_SECURITY_ERR_MSG =
'Forbidden: A security error has occurred. Please check the terminal logs'
/**
* API routes for ComfyUI Manager
*/
enum ManagerRoute {
START_QUEUE = 'manager/queue/start',
RESET_QUEUE = 'manager/queue/reset',
QUEUE_STATUS = 'manager/queue/status',
INSTALL = 'manager/queue/install',
UPDATE = 'manager/queue/update',
UPDATE_ALL = 'manager/queue/update_all',
UNINSTALL = 'manager/queue/uninstall',
DISABLE = 'manager/queue/disable',
FIX_NODE = 'manager/queue/fix',
LIST_INSTALLED = 'customnode/installed',
GET_NODES = 'customnode/getmappings',
GET_PACKS = 'customnode/getlist',
IMPORT_FAIL_INFO = 'customnode/import_fail_info',
REBOOT = 'manager/reboot'
}
const managerApiClient = axios.create({
baseURL: api.apiURL(''),
headers: {
'Content-Type': 'application/json'
}
})
/**
* Service for interacting with the ComfyUI Manager API
* Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions
*/
export const useComfyManagerService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
const didStartQueue = ref(false)
const handleRequestError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
) => {
// Don't treat cancellation as an error
if (isAbortError(err)) return
let message: string
if (!axios.isAxiosError(err)) {
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
}
}
error.value = message
}
const executeRequest = async <T>(
requestCall: () => Promise<AxiosResponse<T>>,
options: {
errorContext: string
routeSpecificErrors?: Record<number, string>
isQueueOperation?: boolean
}
): Promise<T | null> => {
const { errorContext, routeSpecificErrors, isQueueOperation } = options
isLoading.value = true
error.value = null
try {
const response = await requestCall()
if (isQueueOperation) await startQueue()
return response.data
} catch (err) {
handleRequestError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const startQueue = async (signal?: AbortSignal) => {
const errorContext = 'Starting ComfyUI-Manager job queue'
const routeSpecificErrors = {
201: 'Created: ComfyUI-Manager job queue is already running'
}
didStartQueue.value = true
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.START_QUEUE, { signal }),
{ errorContext, routeSpecificErrors }
)
}
const getQueueStatus = async (signal?: AbortSignal) => {
const errorContext = 'Getting ComfyUI-Manager queue status'
return executeRequest<ManagerQueueStatus>(
() => managerApiClient.get(ManagerRoute.QUEUE_STATUS, { signal }),
{ errorContext }
)
}
const resetQueue = async (signal?: AbortSignal) => {
const errorContext = 'Resetting ComfyUI-Manager queue'
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.RESET_QUEUE, { signal }),
{ errorContext }
)
}
const listInstalledPacks = async (signal?: AbortSignal) => {
const errorContext = 'Fetching installed packs'
return executeRequest<InstalledPacksResponse>(
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
{ errorContext }
)
}
const getImportFailInfo = async (signal?: AbortSignal) => {
const errorContext = 'Fetching import failure information'
return executeRequest<any>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
{ errorContext }
)
}
const installPack = async (
params: InstallPackParams,
signal?: AbortSignal
) => {
const errorContext = `Installing pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG,
404:
params.selected_version === SelectedVersion.NIGHTLY
? `Not Found: Node pack ${params.id} does not provide nightly version`
: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.INSTALL, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const uninstallPack = async (
params: ManagerPackInfo,
signal?: AbortSignal
) => {
const errorContext = `Uninstalling pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UNINSTALL, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const disablePack = async (
params: ManagerPackInfo,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Disabling pack ${params.id}`
const routeSpecificErrors = {
404: `Pack ${params.id} not found or not installed`,
409: `Pack ${params.id} is already disabled`
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.DISABLE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const updatePack = async (
params: ManagerPackInfo,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Updating pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UPDATE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const updateAllPacks = async (
params?: UpdateAllPacksParams,
signal?: AbortSignal
) => {
const errorContext = 'Updating all packs'
const routeSpecificErrors = {
403: 'Forbidden: To use this action, a security_level of `middle or below` is required',
401: 'Unauthorized: ComfyUI-Manager job queue is busy'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.UPDATE_ALL, { params, signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const rebootComfyUI = async (signal?: AbortSignal) => {
const errorContext = 'Rebooting ComfyUI'
const routeSpecificErrors = {
403: 'Forbidden: Rebooting ComfyUI requires security_level of middle or below'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.REBOOT, { signal }),
{ errorContext, routeSpecificErrors }
)
}
return {
// State
isLoading,
error,
// Queue operations
startQueue,
resetQueue,
getQueueStatus,
// Pack management
listInstalledPacks,
getImportFailInfo,
installPack,
uninstallPack,
enablePack: installPack, // enable is done via install
disablePack,
updatePack,
updateAllPacks,
// System operations
rebootComfyUI
}
}

View File

@@ -23,6 +23,9 @@ export const useComfyRegistryService = () => {
const nodeDefStore = useNodeDefStore()
const isLocalNodePack = (nodePackId: string) =>
!!nodeDefStore.nodeDefsByName[nodePackId]
const isLocalNode = (nodeName: string, nodePackId: string) => {
if (!nodeDefStore.nodeDefsByName[nodeName]) return false
return (
@@ -31,6 +34,16 @@ export const useComfyRegistryService = () => {
)
}
/**
* Check if the node definitions for the pack are available
*/
const packNodesAvailable = (node: components['schemas']['Node']) => {
if (node.id && isLocalNodePack(node.id)) return true
if (node.latest_version?.comfy_node_extract_status !== 'success')
return false
return true
}
const handleApiError = (
err: unknown,
context: string,
@@ -100,24 +113,23 @@ export const useComfyRegistryService = () => {
}
/**
* Get Comfy Node definition for a specific node in a specific version of a node pack
* Get the Comfy Node definitions in a specific version of a node pack
* @param packId - The ID of the node pack
* @param versionId - The version of the node pack
* @param comfyNodeName - The name of the comfy node (corresponds to `ComfyNodeDef#name`)
* @returns The node definition or null if not found or an error occurred
* @returns The node definitions or null if not found or an error occurred
*/
const getNodeDef = async (
packId: components['schemas']['Node']['id'],
versionId: components['schemas']['NodeVersion']['id'],
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name'],
const getNodeDefs = async (
params: {
packId: components['schemas']['Node']['id']
versionId: components['schemas']['NodeVersion']['id']
},
signal?: AbortSignal
) => {
if (!comfyNodeName || !packId) return null
if (isLocalNode(comfyNodeName, packId))
return nodeDefStore.nodeDefsByName[comfyNodeName]
const { packId, versionId } = params
if (!packId || !versionId) return null
const endpoint = `/nodes/${packId}/versions/${versionId}/comfy-nodes/${comfyNodeName}`
const errorContext = 'Failed to get node definition'
const endpoint = `/nodes/${packId}/versions/${versionId}/comfy-nodes`
const errorContext = 'Failed to get node definitions'
const routeSpecificErrors = {
403: 'This pack has been banned and its definition is not available',
404: 'The requested node, version, or comfy node does not exist'
@@ -125,7 +137,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['ComfyNode']>(endpoint, {
registryApiClient.get<components['schemas']['ComfyNode'][]>(endpoint, {
signal
}),
errorContext,
@@ -133,6 +145,33 @@ export const useComfyRegistryService = () => {
)
}
/**
* Get a Comfy Node definition for a specific node in a specific version of a node pack
* @param packId - The ID of the node pack
* @param versionId - The version of the node pack
* @param comfyNodeName - The name of the comfy node (corresponds to `ComfyNodeDef#name`)
* @returns The node definition or null if not found or an error occurred
*/
const getNodeDef = async (
params: {
packId: components['schemas']['Node']['id']
versionId: components['schemas']['NodeVersion']['id']
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name']
},
signal?: AbortSignal
) => {
const { packId, versionId, comfyNodeName } = params
if (!comfyNodeName || !packId || !versionId) return null
if (isLocalNode(comfyNodeName, packId))
return nodeDefStore.nodeDefsByName[comfyNodeName]
const nodeDefs = await getNodeDefs({ packId, versionId }, signal)
return (
nodeDefs?.find((nodeDef) => nodeDef.comfy_node_name === comfyNodeName) ||
null
)
}
/**
* Get a paginated list of packs matching specific criteria.
* Search packs using `search` param. Search individual nodes using `comfy_node_search` param.
@@ -335,6 +374,8 @@ export const useComfyRegistryService = () => {
getPublisherById,
listPacksForPublisher,
getNodeDef,
postPackReview
getNodeDefs,
postPackReview,
packNodesAvailable
}
}

View File

@@ -5,6 +5,8 @@ import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
@@ -101,6 +103,24 @@ export const useDialogService = () => {
})
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: false,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
}
},
props
})
}
async function prompt({
title,
message,
@@ -182,6 +202,7 @@ export const useDialogService = () => {
showExecutionErrorDialog,
showTemplateWorkflowsDialog,
showIssueReportDialog,
showManagerDialog,
prompt,
confirm
}

View File

@@ -8,25 +8,24 @@ import {
RenderShape
} from '@comfyorg/litegraph'
import { Vector2 } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { st } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ANIM_PREVIEW_WIDGET, ComfyApp, app } from '@/scripts/app'
import { ComfyApp, app } from '@/scripts/app'
import { $el } from '@/scripts/ui'
import { calculateImageGrid, createImageHost } from '@/scripts/ui/imagePreview'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
import { getImageTop, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { useExtensionService } from './extensionService'
@@ -170,7 +169,9 @@ export const useLitegraphService = () => {
addNodeContextMenuHandler(node)
addDrawBackgroundHandler(node)
addNodeKeyHandler(node)
// Note: Some extensions expects node.comfyClass to be set in
// `beforeRegisterNodeDef`.
node.prototype.comfyClass = nodeDefV1.name
await extensionService.invokeExtensionsAsync(
'beforeRegisterNodeDef',
node,
@@ -179,7 +180,6 @@ export const useLitegraphService = () => {
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
node.comfyClass = nodeDef.name
node.prototype.comfyClass = nodeDef.name
node.nodeData = nodeDef
LiteGraph.registerNodeType(nodeId, node)
// Note: Do not following assignments before `LiteGraph.registerNodeType`
@@ -358,20 +358,16 @@ export const useLitegraphService = () => {
* @param {*} node The node to add the draw handler
*/
function addDrawBackgroundHandler(node: typeof LGraphNode) {
/**
* @deprecated No longer needed as we use {@link useImagePreviewWidget}
*/
node.prototype.setSizeForImage = function (
this: LGraphNode,
force: boolean
) {
if (!force && this.animatedImages) return
if (this.inputHeight || this.freeWidgetSpace > 210) {
this.setSize(this.size)
return
}
const minHeight = getImageTop(this) + 220
if (this.size[1] < minHeight) {
this.setSize([this.size[0], minHeight])
}
console.warn(
'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.'
)
}
function unsafeDrawBackground(
@@ -381,6 +377,10 @@ export const useLitegraphService = () => {
if (this.flags.collapsed) return
const nodeOutputStore = useNodeOutputStore()
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
const { showCanvasImagePreview, removeCanvasImagePreview } =
useNodeCanvasImagePreview()
const output = nodeOutputStore.getNodeOutputs(this)
const preview = nodeOutputStore.getNodePreviews(this)
@@ -409,259 +409,12 @@ export const useLitegraphService = () => {
// Nothing to do
if (!this.imgs?.length) return
const widgetIdx = this.widgets?.findIndex(
(w) => w.name === ANIM_PREVIEW_WIDGET
)
if (this.animatedImages) {
// Instead of using the canvas we'll use a IMG
if (widgetIdx > -1) {
// Replace content
const widget = this.widgets[widgetIdx] as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.options.host.updateImages(this.imgs)
} else {
const host = createImageHost(this)
this.setSizeForImage(true)
// @ts-expect-error host is not a standard DOM widget option.
const widget = this.addDOMWidget(
ANIM_PREVIEW_WIDGET,
'img',
host.el,
{
host,
// @ts-expect-error `getHeight` of image host returns void instead of number.
getHeight: host.getHeight,
onDraw: host.onDraw,
hideOnZoom: false
}
) as IWidget & {
options: { host: ReturnType<typeof createImageHost> }
}
widget.serializeValue = () => undefined
widget.options.host.updateImages(this.imgs)
}
return
}
if (widgetIdx > -1) {
this.widgets[widgetIdx].onRemove?.()
this.widgets.splice(widgetIdx, 1)
}
const canvas = app.graph.list_of_graphcanvas[0]
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && this.pointerDown) {
if (
mouse[0] === this.pointerDown.pos[0] &&
mouse[1] === this.pointerDown.pos[1]
) {
this.imageIndex = this.pointerDown.index
}
this.pointerDown = null
}
let { imageIndex } = this
const numImages = this.imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
this.imageIndex = imageIndex = 0
}
const shiftY = getImageTop(this)
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = 15
const dw = this.size[0]
const dh = this.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(this.imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = this.imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = this.imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
this.imgs,
dw,
dh
))
}
let anyHovered = false
this.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = this.imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + this.pos[0],
y + this.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
this.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!this.pointerDown || this.pointerDown.index !== i) {
this.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
this.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
this.pointerDown = null
this.overIndex = null
}
return
}
// Draw individual
const img = this.imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
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,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + this.pos[0],
y + this.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = this.imageIndex + 1
if (
drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)
) {
const i = imageNum >= numImages ? 0 : imageNum
if (!this.pointerDown || this.pointerDown.index !== i) {
this.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!this.pointerDown || this.pointerDown.index !== null) {
this.pointerDown = { index: null, pos: [...mouse] }
}
removeCanvasImagePreview(this)
showAnimatedPreview(this)
} else {
removeAnimatedPreview(this)
showCanvasImagePreview(this)
}
}

View File

@@ -332,7 +332,9 @@ export const useWorkflowService = () => {
skip_events: true,
skip_render: true
})
canvas.reroutesEnabled = app.canvas.reroutesEnabled
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Temporary fix for Litegraph issue.
canvas.reroutesEnabled = true
canvas.selectItems()
canvas.copyToClipboard()
app.canvas.pasteFromClipboard(options)

View File

@@ -0,0 +1,117 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { useComfyManagerService } from '@/services/comfyManagerService'
import {
InstallPackParams,
InstalledPacksResponse,
ManagerPackInfo,
UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
/**
* Store for state of installed node packs
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const managerService = useComfyManagerService()
const installedPacks = ref<InstalledPacksResponse>({})
const isStale = ref(true)
const { statusMessage, allTasksDone, enqueueTask } = useManagerQueue()
const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks()
if (packs) installedPacks.value = packs
isStale.value = false
}
const setStale = () => {
isStale.value = true
}
whenever(isStale, refreshInstalledList, { immediate: true })
const installPack = useCachedRequest<InstallPackParams, void>(
async (params: InstallPackParams, signal?: AbortSignal) => {
enqueueTask({
task: () => managerService.installPack(params, signal),
onComplete: setStale
})
},
{ maxSize: 1 }
)
const uninstallPack = (params: ManagerPackInfo, signal?: AbortSignal) => {
installPack.clear()
installPack.cancel()
enqueueTask({
task: () => managerService.uninstallPack(params, signal),
onComplete: setStale
})
}
const updatePack = useCachedRequest<ManagerPackInfo, void>(
async (params: ManagerPackInfo, signal?: AbortSignal) => {
updateAllPacks.clear()
updateAllPacks.cancel()
enqueueTask({
task: () => managerService.updatePack(params, signal),
onComplete: setStale
})
},
{ maxSize: 1 }
)
const updateAllPacks = useCachedRequest<UpdateAllPacksParams, void>(
async (params: UpdateAllPacksParams, signal?: AbortSignal) => {
enqueueTask({
task: () => managerService.updateAllPacks(params, signal),
onComplete: setStale
})
},
{ maxSize: 1 }
)
const disablePack = (params: ManagerPackInfo, signal?: AbortSignal) => {
enqueueTask({
task: () => managerService.disablePack(params, signal),
onComplete: setStale
})
}
const isPackInstalled = (packName: string | undefined): boolean => {
if (!packName) return false
return !!installedPacks.value[packName]
}
const isPackEnabled = (packName: string | undefined): boolean => {
if (!packName) return false
return !!installedPacks.value[packName]?.enabled
}
return {
// Manager state
isLoading: managerService.isLoading,
error: managerService.error,
statusMessage,
allTasksDone,
// Installed packs state
installedPacks,
isPackInstalled,
isPackEnabled,
// Pack actions
installPack,
uninstallPack,
updatePack,
updateAllPacks,
disablePack,
enablePack: installPack // Enable is done via install endpoint with a disabled pack
}
})

View File

@@ -12,6 +12,7 @@ type NodePack = components['schemas']['Node']
type ListPacksParams = operations['listAllNodes']['parameters']['query']
type ListPacksResult =
operations['listAllNodes']['responses'][200]['content']['application/json']
type ComfyNode = components['schemas']['ComfyNode']
/**
* Store for managing remote custom nodes
@@ -23,6 +24,9 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
typeof useCachedRequest<ListPacksParams, ListPacksResult>
>
let getPackByIdHandler: ReturnType<typeof useCachedRequest<string, NodePack>>
let getNodeDefsHandler: ReturnType<
typeof useCachedRequest<{ packId: string; versionId: string }, ComfyNode[]>
>
const recentListResult = ref<NodePack[]>([])
const hasPacks = computed(() => recentListResult.value.length > 0)
@@ -58,6 +62,23 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
return getPackByIdHandler.call(packId)
}
/**
* Get the node definitions for a pack
*/
const getNodeDefs = async (
packId: NodePack['id'],
versionId: NonNullable<NodePack['latest_version']>['id']
) => {
if (!packId || !versionId) return null
getNodeDefsHandler ??= useCachedRequest<
{ packId: string; versionId: string },
ComfyNode[]
>(registryService.getNodeDefs, { maxSize: PACK_BY_ID_CACHE_SIZE })
return getNodeDefsHandler.call({ packId, versionId })
}
/**
* Clear all cached data
*/
@@ -80,6 +101,8 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
listAllPacks,
getPackById,
getNodeDefs,
clearCache,
cancelRequests,

View File

@@ -11,6 +11,7 @@ interface CustomDialogComponentProps {
maximizable?: boolean
maximized?: boolean
onClose?: () => void
closable?: boolean
pt?: DialogPassThroughOptions
}

View File

@@ -4,31 +4,47 @@
import { defineStore } from 'pinia'
import { markRaw, ref } from 'vue'
import type { DOMWidget } from '@/scripts/domWidget'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { BaseDOMWidget } from '@/scripts/domWidget'
export interface DomWidgetState extends PositionConfig {
visible: boolean
readonly: boolean
zIndex: number
}
export const useDomWidgetStore = defineStore('domWidget', () => {
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
// Map to reference actual widget instances
// Widgets are stored as raw values to avoid reactivity issues
const widgetInstances = ref(
new Map<string, DOMWidget<HTMLElement, object | string>>()
)
const widgetInstances = ref(new Map<string, BaseDOMWidget<object | string>>())
// Register a widget with the store
const registerWidget = <T extends HTMLElement, V extends object | string>(
widget: DOMWidget<T, V>
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
) => {
widgetInstances.value.set(
widget.id,
markRaw(widget as unknown as DOMWidget<HTMLElement, object | string>)
markRaw(widget) as unknown as BaseDOMWidget<object | string>
)
widgetStates.value.set(widget.id, {
visible: true,
readonly: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0]
})
}
// Unregister a widget from the store
const unregisterWidget = (widgetId: string) => {
widgetInstances.value.delete(widgetId)
widgetStates.value.delete(widgetId)
}
return {
widgetStates,
widgetInstances,
registerWidget,
unregisterWidget

View File

@@ -1,23 +1,34 @@
import { groupBy } from 'lodash'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import type {
TemplateGroup,
WorkflowTemplates
} from '@/types/workflowTemplateTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
const { t } = useI18n()
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
const groupedTemplates = computed<TemplateGroup[]>(() => {
const allTemplates = [
...coreTemplates.value,
...coreTemplates.value.map((template) => ({
...template,
title: t(
`templateWorkflows.category.${normalizeI18nKey(template.title)}`,
{
defaultValue: template.title
}
)
})),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
@@ -33,8 +44,14 @@ export const useWorkflowTemplatesStore = defineStore(
]
return Object.entries(
groupBy(allTemplates, (t) =>
t.moduleName === 'default' ? 'ComfyUI Examples' : 'Custom Nodes'
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? t('templateWorkflows.category.ComfyUI Examples', {
defaultValue: 'ComfyUI Examples'
})
: t('templateWorkflows.category.Custom Nodes', {
defaultValue: 'Custom Nodes'
})
)
).map(([label, modules]) => ({ label, modules }))
})

View File

@@ -0,0 +1,201 @@
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { components } from '@/types/comfyRegistryTypes'
type RegistryPack = components['schemas']['Node']
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
export type PackField = keyof RegistryPack | null
export type PackWithSelectedVersion = {
nodePack: RegistryPack
selectedVersion?: InstallPackParams['selected_version']
}
export interface TabItem {
id: string
label: string
icon: string
}
export interface SearchOption<T> {
id: T
label: string
}
enum ManagerPackState {
/** Pack is installed and enabled */
INSTALLED = 'installed',
/** Pack is installed but disabled */
DISABLED = 'disabled',
/** Pack is not installed */
NOT_INSTALLED = 'not_installed',
/** Pack failed to import */
IMPORT_FAILED = 'import_failed',
/** Pack has an update available */
NEEDS_UPDATE = 'needs_update'
}
enum ManagerPackInstallType {
/** Installed via git clone */
GIT = 'git-clone',
/** Installed via file copy */
COPY = 'copy',
/** Installed from the Comfy Registry */
REGISTRY = 'cnr'
}
export enum SelectedVersion {
/** Latest version of the pack from the registry */
LATEST = 'latest',
/** Latest commit of the pack from its GitHub repository */
NIGHTLY = 'nightly'
}
export enum ManagerChannel {
/** All packs except those with instability or security issues */
DEFAULT = 'default',
/** Packs that were recently updated */
RECENT = 'recent',
/** Packs that were superseded by distinct replacements of some type */
LEGACY = 'legacy',
/** Packs that were forked as a result of the original pack going unmaintained */
FORKED = 'forked',
/** Packs with instability or security issues suitable only for developers */
DEV = 'dev',
/** Packs suitable for beginners */
TUTORIAL = 'tutorial'
}
export enum ManagerDatabaseSource {
/** Get pack info from the Comfy Registry */
REMOTE = 'remote',
/** If set to `local`, the channel is ignored */
LOCAL = 'local',
/** Get pack info from the cached response from the Comfy Registry (1 day TTL) */
CACHE = 'cache'
}
export interface ManagerQueueStatus {
/** `done_count` + `in_progress_count` + number of items queued */
total_count: number
/** Task worker thread is alive, a queued operation is running */
is_processing: boolean
/** Number of items in the queue that have been completed */
done_count: number
/** Number of items in the queue that are currently running */
in_progress_count: number
}
export interface ManagerPackInfo {
/** Either github-author/github-repo or name of pack from the registry (not id) */
id: WorkflowNodeProperties['aux_id'] | WorkflowNodeProperties['cnr_id']
/** Semantic version or Git commit hash */
version: WorkflowNodeProperties['ver']
}
export interface ManagerPackInstalled {
/**
* The version of the pack that is installed.
* Git commit hash or semantic version.
*/
ver: WorkflowNodeProperties['ver']
/**
* The name of the pack if the pack is installed from the registry.
* Corresponds to `Node#name` in comfy-api.
*/
cnr_id: WorkflowNodeProperties['cnr_id']
/**
* The name of the pack if the pack is installed from github.
* In the format author/repo-name. If the pack is installed from the registry, this is `null`.
*/
aux_id: WorkflowNodeProperties['aux_id'] | null
enabled: boolean
}
/**
* Returned by `/customnode/installed`
*/
export type InstalledPacksResponse = Record<
NonNullable<RegistryPack['name']>,
ManagerPackInstalled
>
/**
* Returned by `/customnode/getlist`
*/
export interface ManagerPack extends ManagerPackInfo {
/** Pack author name or 'Unclaimed' if the pack was added automatically via GitHub crawl. */
author: components['schemas']['Node']['author']
/** Files included in the pack */
files: string[]
/** The type of installation that was used to install the pack */
reference: string
/** The display name of the pack */
title: string
/** The latest version of the pack */
cnr_latest: SelectedVersion
/** The github link to the repository of the pack */
repository: string
/** The state of the pack */
state: ManagerPackState
/** The state of the pack update */
'update-state': 'false' | 'true' | null
/** The number of stars the pack has on GitHub. Distinct from registry stars */
stars: number
/**
* The last time the pack was updated. In ISO 8601 format.
* @example '2024-05-22 20:00:00'
*/
last_update: string
health: string
description: string
trust: boolean
install_type: ManagerPackInstallType
}
/**
* Returned by `/customnode/getmappings`.
*/
export type ManagerMappings = Record<
NonNullable<components['schemas']['Node']['name']>,
[
/** List of ComfyNode names included in the pack */
Array<components['schemas']['ComfyNode']['comfy_node_name']>,
{
/** The display name of the pack */
title_aux: string
}
]
>
/**
* Payload for `/manager/queue/install`
*/
export interface InstallPackParams extends ManagerPackInfo {
/**
* Semantic version, Git commit hash, `latest`, or `nightly`.
*/
selected_version: WorkflowNodeProperties['ver'] | SelectedVersion
/**
* If set to `imported`, returns only the packs that were imported at app startup.
*/
mode?: 'imported' | 'default'
/**
* The GitHub link to the repository of the pack to install.
* Required if `selected_version` is `nightly`.
*/
repository: string
/**
* List of PyPi dependency names associated with the pack.
* Used in coordination with pip package whitelist and version lock features.
*/
pip?: string[]
channel: ManagerChannel
skip_post_install?: boolean
}
/**
* Params for `/manager/queue/update_all`
*/
export interface UpdateAllPacksParams {
mode?: ManagerDatabaseSource
}

View File

@@ -26,6 +26,19 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
* The minimum size of the node if the widget is present.
*/
minNodeSize?: Size
/**
* Whether the widget is optional.
*/
inputIsOptional?: boolean
/**
* Whether the widget is forced to be an input.
*/
forceInput?: boolean
/**
* Whether the widget defaults to input state. Can still be converted back
* to widget state.
*/
defaultInput?: boolean
}
interface IBaseWidget {
@@ -134,7 +147,7 @@ declare module '@comfyorg/litegraph' {
name: string,
type: string,
element: T,
options?: DOMWidgetOptions<T, V>
options?: DOMWidgetOptions<V>
): DOMWidget<T, V>
animatedImages?: boolean
@@ -153,7 +166,9 @@ declare module '@comfyorg/litegraph' {
imageRects: Rect[]
overIndex?: number | null
pointerDown?: { index: number | null; pos: Point } | null
/**
* @deprecated No longer needed as we use {@link useImagePreviewWidget}
*/
setSizeForImage?(force?: boolean): void
/** @deprecated Unused */
inputHeight?: unknown

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