Compare commits

..

3 Commits

Author SHA1 Message Date
filtered
628b44051b nit - fix TS type 2025-01-22 08:26:22 +11:00
filtered
a7a5e3cf67 [Refactor] Task state updates to TaskRunner 2025-01-22 08:18:42 +11:00
filtered
64e218a9f3 [Refactor] Task execution into task runner class 2025-01-22 08:18:42 +11:00
77 changed files with 1761 additions and 3200 deletions

View File

@@ -6,7 +6,6 @@ on:
branches:
- main
- master
- core/*
paths:
- "package.json"
@@ -44,7 +43,6 @@ jobs:
draft: true
prerelease: false
make_latest: "true"
generate_release_notes: true
publish_types:
runs-on: ubuntu-latest
if: >

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@ dist-ssr
!.vscode/extensions.json
!.vscode/tailwind.json
!.vscode/settings.json.default
!.vscode/launch.json.default
.idea
.DS_Store
*.suo

View File

@@ -1,16 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome on frontend dev",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true,
}
]
}

View File

@@ -1,4 +1,4 @@
import { Locator, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { Keybinding } from '../src/types/keyBindingTypes'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
@@ -66,71 +66,9 @@ test.describe('Missing models warning', () => {
}, comfyPage.url)
})
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadButton = missingModelsWarning.getByLabel('Download')
await expect(downloadButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
comfyPage
}) => {
const modelFoldersRes = {
status: 200,
body: JSON.stringify([
{
name: 'clip',
folders: ['ComfyUI/models/clip']
}
])
}
comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
)
// Reload page to trigger indexing of model folders
await comfyPage.setup()
const clipModelsRes = {
status: 200,
body: JSON.stringify([
{
name: 'fake_model.safetensors',
pathIndex: 0
}
])
}
comfyPage.page.route(
'**/api/experiment/models/clip',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
)
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
test('should show on tutorial workflow', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({ clearStorage: true })
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
expect(await comfyPage.getSetting('Comfy.TutorialCompleted')).toBe(true)
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({
test.skip('Should display a warning when missing models are found', async ({
comfyPage
}) => {
// The fake_model.safetensors is served by
@@ -148,49 +86,6 @@ test.describe('Missing models warning', () => {
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
test.describe('Do not show again checkbox', () => {
let checkbox: Locator
let closeButton: Locator
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.loadWorkflow('missing_models')
checkbox = comfyPage.page.getByLabel("Don't show this again")
closeButton = comfyPage.page.getByLabel('Close')
})
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(false)
})
test('Should keep warning dialog enabled when checkbox is unchecked', async ({
comfyPage
}) => {
await closeButton.click()
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(true)
})
})
})
test.describe('Settings', () => {

View File

@@ -904,9 +904,7 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
// Disable tooltips by default to avoid flakiness.
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true
'Comfy.userId': userId
})
} catch (e) {
console.error(e)

View File

@@ -102,18 +102,6 @@ test.describe('Node Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
// The submenu has an identical entry as the base menu - use last
await comfyPage.page.getByText('Convert width to input').last().click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
test('Can convert widget without submenu', async ({ comfyPage }) => {
// Right-click the width widget
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

12
global.d.ts vendored
View File

@@ -1,15 +1,3 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
interface Navigator {
/**
* Used by the electron API. This is a WICG non-standard API, but is guaranteed to exist in Electron.
* It is `undefined` in Firefox and older browsers.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/windowControlsOverlay
*/
windowControlsOverlay?: {
/** When `true`, the window is using custom window style. */
visible: boolean
}
}

20
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.8.14",
"version": "1.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.8.14",
"version": "1.8.2",
"license": "GPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.62",
"@comfyorg/comfyui-electron-types": "^0.4.11",
"@comfyorg/litegraph": "^0.8.61",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -1937,15 +1937,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.16.tgz",
"integrity": "sha512-AKy4WLVAuDka/Xjv8zrKwfU/wfRSQpFVE5DgxoLfvroCI0sw+rV1JqdL6xFVrYIoeprzbfKhQiyqlAWU+QgHyg==",
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.11.tgz",
"integrity": "sha512-RGJeWwXjyv0Ojj7xkZKgcRxC1nFv1nh7qEWpNBiofxVgFiap9Ei79b/KJYxNE0no4BoYqRMaRg+sFtCE6yEukA==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.62",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.62.tgz",
"integrity": "sha512-El2QO8m1ky0YB/tUTDA3sorhtzZgBItMZ+j80eO9fAQb/ptToTUId+DxWLAZyz0qhA7RXJFA27emX+sdtWYO9g==",
"version": "0.8.61",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.61.tgz",
"integrity": "sha512-7DroJ0PLgI9TFvQR//6rf0NRXRvV60hapxVX5lmKzNn4Mn2Ni/JsB2ypNLKeSU5sacNyu8QT3W5Jdpafl7lcnA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.8.14",
"version": "1.8.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -83,8 +83,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.62",
"@comfyorg/comfyui-electron-types": "^0.4.11",
"@comfyorg/litegraph": "^0.8.61",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -266,7 +266,7 @@
],
"properties": {},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
"v1-5-pruned-emaonly.safetensors"
]
}
],
@@ -349,8 +349,8 @@
"extra": {},
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
}

View File

@@ -765,30 +765,6 @@ audio.comfy-audio.empty-audio-widget {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
/* Load3d styles */
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation{
display: flex;
flex-direction: column;
background: transparent;
flex: 1;
position: relative;
overflow: hidden;
}
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas{
display: flex;
width: 100% !important;
height: 100% !important;
}
/* End of Load3d styles */
/* [Desktop] Electron window specific styles */
.app-drag {
app-region: drag;

View File

@@ -42,7 +42,7 @@
v-else
class="pi pi-palette"
:style="{ fontSize: '1.2rem' }"
v-tooltip="$t('color.custom')"
v-tooltip="$t('g.customColor')"
></i>
</template>
</SelectButton>

View File

@@ -35,7 +35,6 @@ import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import { FormItem } from '@/types/settingTypes'
const formValue = defineModel<any>('formValue')
@@ -92,8 +91,6 @@ function getFormComponent(item: FormItem): Component {
return FormImageUpload
case 'color':
return FormColorPicker
case 'url':
return UrlInput
default:
return InputText
}

View File

@@ -9,7 +9,6 @@
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onContextmenu: (e: MouseEvent) =>

View File

@@ -27,16 +27,13 @@
class="leaf-count-badge"
/>
</div>
<div
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<div class="node-actions">
<slot name="actions" :node="props.node"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { Ref, computed, inject, ref } from 'vue'
@@ -105,17 +102,7 @@ if (props.node.draggable) {
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node),
onGenerateDragPreview: props.node.renderDragPreview
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview(props.node, container)
},
nativeSetDragImage
})
}
: undefined
onDrop: () => emit('dragEnd', props.node)
})
}

View File

@@ -1,116 +0,0 @@
<template>
<IconField class="w-full">
<InputText
v-bind="$attrs"
:model-value="internalValue"
class="w-full"
:invalid="validationState === ValidationState.INVALID"
@update:model-value="handleInput"
@blur="handleBlur"
/>
<InputIcon
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500 cursor-pointer':
validationState === ValidationState.VALID,
'pi pi-times text-red-500 cursor-pointer':
validationState === ValidationState.INVALID
}"
@click="validateUrl(props.modelValue)"
/>
</IconField>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { onMounted, ref, watch } from 'vue'
import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'state-change': [state: ValidationState]
}>()
const validationState = ref<ValidationState>(ValidationState.IDLE)
// Add internal value state
const internalValue = ref(props.modelValue)
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = newValue
await validateUrl(newValue)
}
)
watch(validationState, (newState) => {
emit('state-change', newState)
})
// Validate on mount
onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string) => {
// Update internal value without emitting
internalValue.value = value
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
const handleBlur = async () => {
// Emit the update only on blur
emit('update:modelValue', internalValue.value)
}
// Default validation implementation
const defaultValidateUrl = async (url: string): Promise<boolean> => {
if (!isValidUrl(url)) return false
try {
return await checkUrlReachable(url)
} catch {
return false
}
}
const validateUrl = async (value: string) => {
if (validationState.value === ValidationState.LOADING) return
const url = value.trim()
// Reset state
validationState.value = ValidationState.IDLE
// Skip validation if empty
if (!url) return
validationState.value = ValidationState.LOADING
try {
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? ValidationState.VALID
: ValidationState.INVALID
} catch {
validationState.value = ValidationState.INVALID
}
}
// Add inheritAttrs option to prevent attrs from being applied to root element
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -1,158 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from '../UrlInput.vue'
describe('UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: any, options = {}) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
components: { IconField, InputIcon, InputText }
},
props,
...options
})
}
it('passes through additional attributes to input element', () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
disabled: true
})
expect(wrapper.find('input').attributes('disabled')).toBe('')
})
it('emits update:modelValue on blur', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
await input.setValue('https://test.com')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
'https://test.com'
])
})
it('renders spinner when validation is loading', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () =>
new Promise(() => {
// Never resolves, simulating perpetual loading state
})
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-spinner').exists()).toBe(true)
})
it('renders check icon when validation is valid', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(true)
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
})
it('renders cross icon when validation is invalid', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(false)
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-times').exists()).toBe(true)
})
it('validates on mount', async () => {
const wrapper = mountComponent({
modelValue: 'https://test.com',
validateUrlFn: () => Promise.resolve(true)
})
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
})
it('triggers validation when clicking the validation icon', async () => {
let validationCount = 0
const wrapper = mountComponent({
modelValue: 'https://test.com',
validateUrlFn: () => {
validationCount++
return Promise.resolve(true)
}
})
// Wait for initial validation
await nextTick()
await nextTick()
// Click the validation icon
await wrapper.find('.pi-check').trigger('click')
await nextTick()
await nextTick()
expect(validationCount).toBe(2) // Once on mount, once on click
})
it('prevents multiple simultaneous validations', async () => {
let validationCount = 0
const wrapper = mountComponent({
modelValue: '',
validateUrlFn: () => {
validationCount++
return new Promise(() => {
// Never resolves, simulating perpetual loading state
})
}
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
await nextTick()
await nextTick()
expect(validationCount).toBe(1) // Only the initial validation should occur
})
})

View File

@@ -2,15 +2,9 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
title="Missing Models"
message="When loading the graph, the following models were not found"
/>
<div class="flex gap-1 mb-4">
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isElectron()">
@@ -31,14 +25,11 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/stores/settingStore'
import { isElectron } from '@/utils/envUtil'
// TODO: Read this from server internal API rather than hardcoding here
@@ -67,10 +58,6 @@ const props = defineProps<{
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
@@ -120,12 +107,6 @@ const missingModels = computed(() => {
}
})
})
onBeforeUnmount(() => {
if (doNotAskAgain.value) {
useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', false)
}
})
</script>
<style scoped>

View File

@@ -50,7 +50,7 @@
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
v-if="$field?.error && $field.touched"
severity="error"
size="small"
variant="simple"
@@ -105,7 +105,6 @@ import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
@@ -219,7 +218,7 @@ const createCaptureContext = async (
? formData.notifyOnResolution
: false,
isElectron: isElectron(),
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
...props.tags
},
extra: {
details: formData.details,

View File

@@ -29,7 +29,18 @@
</template>
<script setup lang="ts">
import { CanvasPointer, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import {
CanvasPointer,
ContextMenu,
DragAndScale,
LGraph,
LGraphBadge,
LGraphCanvas,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph
} from '@comfyorg/litegraph'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -42,29 +53,37 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { useCanvasDrop } from '@/hooks/canvasDropHooks'
import { useGlobalLitegraph } from '@/hooks/litegraphHooks'
import { useWorkflowPersistence } from '@/hooks/workflowPersistenceHooks'
import { i18n } from '@/i18n'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyModelDef } from '@/stores/modelStore'
import {
ModelNodeProvider,
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
const litegraphService = useLitegraphService()
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -76,6 +95,29 @@ const canvasMenuEnabled = computed(() =>
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const storedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
const openWorkflows = computed(() => workspaceStore?.workflow?.openWorkflows)
const activeWorkflow = computed(() => workspaceStore?.workflow?.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
return { paths, activeIndex }
})
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
@@ -227,36 +269,105 @@ watch(
}
)
watchEffect(() => {
LiteGraph.context_menu_scaling = settingStore.get(
'LiteGraph.ContextMenu.Scaling'
)
})
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
Object.entries(i18nData).forEach(([locale, message]) => {
i18n.global.mergeLocaleMessage(locale, message)
})
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
const workflowStore = useWorkflowStore()
const persistCurrentWorkflow = () => {
const workflow = JSON.stringify(comfyApp.serializeGraph())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
useCanvasDrop(canvasRef)
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.key)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
})
api.addEventListener('graphChanged', persistCurrentWorkflow)
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
const comfyAppReady = ref(false)
onMounted(async () => {
useGlobalLitegraph()
// Backward compatible
// Assign all properties of lg to window
window['LiteGraph'] = LiteGraph
window['LGraph'] = LGraph
window['LLink'] = LLink
window['LGraphNode'] = LGraphNode
window['LGraphGroup'] = LGraphGroup
window['DragAndScale'] = DragAndScale
window['LGraphCanvas'] = LGraphCanvas
window['ContextMenu'] = ContextMenu
window['LGraphBadge'] = LGraphBadge
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
await loadCustomNodesI18n()
await settingStore.loadSettingValues()
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
@@ -276,9 +387,17 @@ onMounted(async () => {
'Comfy.CustomColorPalettes'
)
// Restore workflow and workflow tabs state from storage
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable)
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
watch(restoreState, ({ paths, activeIndex }) => {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
})
// Start watching for locale change after the initial value is loaded.
watch(

View File

@@ -1,104 +0,0 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
:item="item"
v-model="modelValue.value"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
v-tooltip="validationStateTooltip"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import {
CUDA_TORCH_URL,
NIGHTLY_CPU_TORCH_URL,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import { ModelRef, computed, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: NIGHTLY_CPU_TORCH_URL,
fallbackMirror: NIGHTLY_CPU_TORCH_URL
}
case 'nvidia':
return {
settingId,
mirror: CUDA_TORCH_URL,
fallbackMirror: CUDA_TORCH_URL
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() => [
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
])
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
</script>

View File

@@ -1,60 +0,0 @@
<template>
<div class="flex flex-col items-center gap-4">
<div class="w-full">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t(`settings.${normalizedSettingId}.name`) }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p>
</div>
<UrlInput
v-model="modelValue"
:validate-url-fn="
(mirror: string) =>
checkMirrorReachable(mirror + (item.validationPathSuffix ?? ''))
"
@state-change="validationState = $event"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { UVMirror } from '@/constants/uvMirrors'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { item } = defineProps<{
item: UVMirror
}>()
const emit = defineEmits<{
'state-change': [state: ValidationState]
}>()
const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE)
const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId)
})
onMounted(() => {
modelValue.value = item.mirror
})
watch(validationState, (newState) => {
emit('state-change', newState)
// Set fallback mirror if default mirror is invalid
if (
newState === ValidationState.INVALID &&
modelValue.value === item.mirror
) {
modelValue.value = item.fallbackMirror
}
})
</script>

View File

@@ -3,9 +3,9 @@
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { PrimeIcons, type PrimeIconsOptions } from '@primevue/core/api'
import Tag, { TagProps } from 'primevue/tag'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
@@ -16,21 +16,25 @@ const props = defineProps<{
}>()
// Bindings
const icon = computed(() => {
if (props.refreshing) return PrimeIcons.QUESTION
if (props.error) return PrimeIcons.TIMES
return PrimeIcons.CHECK
})
const icon = ref<string>(null)
const severity = ref<TagProps['severity']>(null)
const value = ref<PrimeIconsOptions[keyof PrimeIconsOptions]>(null)
const severity = computed(() => {
if (props.refreshing) return 'info'
if (props.error) return 'danger'
return 'success'
})
const updateBindings = () => {
if (props.refreshing) {
icon.value = PrimeIcons.QUESTION
severity.value = 'info'
value.value = t('maintenance.refreshing')
} else if (props.error) {
icon.value = PrimeIcons.TIMES
severity.value = 'danger'
value.value = t('g.error')
} else {
icon.value = PrimeIcons.CHECK
severity.value = 'success'
value.value = t('maintenance.OK')
}
}
const value = computed(() => {
if (props.refreshing) return t('maintenance.refreshing')
if (props.error) return t('g.error')
return t('maintenance.OK')
})
watch(props, updateBindings, { deep: true })
</script>

View File

@@ -11,6 +11,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import { MaybeRef, computed } from 'vue'
import { t } from '@/i18n'
import { MaintenanceTaskState } from '@/stores/maintenanceTaskStore'
// Properties
const tooltip = computed(() => {
@@ -38,7 +39,7 @@ const cssClasses = computed(() => {
// Model
const props = defineProps<{
state: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
state?: MaintenanceTaskState
loading?: MaybeRef<boolean>
}>()
</script>

View File

@@ -60,9 +60,8 @@
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
<SearchFilterChip
v-if="Array.isArray(value) && value.length === 2"
:key="`${value[0].id}-${value[1]}`"
@remove="onRemoveFilter($event, value as FilterAndValue)"
@remove="onRemoveFilter($event, value)"
:text="value[1]"
:badge="value[0].invokeSequence.toUpperCase()"
:badge-class="value[0].invokeSequence + '-badge'"

View File

@@ -66,12 +66,11 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { TreeNode } from 'primevue/treenode'
import { Ref, computed, h, nextTick, ref, render } from 'vue'
import { Ref, computed, nextTick, ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
@@ -126,13 +125,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
},
children,
draggable: node.leaf,
renderDragPreview: (node, container) => {
const vnode = h(NodePreview, { nodeDef: node.data })
render(vnode, container)
return () => {
render(null, container)
}
},
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent

View File

@@ -1,6 +1,6 @@
<template>
<div
class="comfy-vue-side-bar-container flex flex-col h-full group/sidebar-tab"
class="comfy-vue-side-bar-container flex flex-col h-full"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
@@ -11,11 +11,7 @@
</span>
</template>
<template #end>
<div
class="flex flex-row motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 transition-all duration-200"
>
<slot name="tool-buttons"></slot>
</div>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<slot name="header"></slot>

View File

@@ -7,7 +7,6 @@
<Button
class="browse-templates-button"
icon="pi pi-th-large"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.browseTemplates')"
text
@click="() => commandStore.execute('Comfy.BrowseTemplates')"
@@ -15,7 +14,6 @@
<Button
class="open-workflow-button"
icon="pi pi-folder-open"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.openWorkflow')"
text
@click="() => commandStore.execute('Comfy.OpenWorkflow')"
@@ -23,7 +21,6 @@
<Button
class="new-blank-workflow-button"
icon="pi pi-plus"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
text

View File

@@ -33,7 +33,7 @@
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
v-show="isNativeWindow && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
@@ -50,12 +50,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeMenu
} from '@/utils/envUtil'
import { electronAPI, isElectron, showNativeMenu } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -69,6 +64,10 @@ const teleportTarget = computed(() =>
? '.comfyui-body-top'
: '.comfyui-body-bottom'
)
const isNativeWindow = computed(
() =>
isElectron() && settingStore.get('Comfy-Desktop.WindowStyle') === 'custom'
)
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)

View File

@@ -157,7 +157,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Workflow.ShowMissingModelsWarning',
name: 'Show missing models warning',
type: 'boolean',
defaultValue: true,
defaultValue: false,
experimental: true
},
{
@@ -290,7 +290,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Node ID badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.None
defaultValue: NodeBadgeMode.ShowAll
},
{
id: 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
@@ -708,19 +708,5 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 'after',
options: ['before', 'after'],
versionModified: '1.6.10'
},
{
id: 'Comfy.TutorialCompleted',
name: 'Tutorial completed',
type: 'hidden',
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: 'LiteGraph.ContextMenu.Scaling',
name: 'Scale node combo widget menus (lists) when zoomed in',
defaultValue: false,
type: 'boolean',
versionAdded: '1.8.8'
}
]

View File

@@ -31,10 +31,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: () => openUrl('https://git-scm.com/downloads/'),
name: 'Download git',
shortDescription: 'Open the git download page.',
errorDescription:
'Git is missing. Please download and install git, then restart ComfyUI Desktop.',
description:
'Git is required to download and manage custom nodes and other extensions. This task opens the download page in your default browser, where you can download the latest version of git. Once you have installed git, please restart ComfyUI Desktop.',
'Git is required to download and manage custom nodes and other extensions. This fixer simply opens the download page in your browser. You must download and install git manually.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'

View File

@@ -1,34 +0,0 @@
export interface UVMirror {
/**
* The setting id defined for the mirror.
*/
settingId: string
/**
* The default mirror to use.
*/
mirror: string
/**
* The fallback mirror to use.
*/
fallbackMirror: string
/**
* The path suffix to validate the mirror is reachable.
*/
validationPathSuffix?: string
}
export const PYTHON_MIRROR: UVMirror = {
settingId: 'Comfy-Desktop.UV.PythonInstallMirror',
mirror:
'https://github.com/astral-sh/python-build-standalone/releases/download',
fallbackMirror:
'https://bgithub.xyz/astral-sh/python-build-standalone/releases/download',
validationPathSuffix:
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
}
export const PYPI_MIRROR: UVMirror = {
settingId: 'Comfy-Desktop.UV.PypiInstallMirror',
mirror: 'https://pypi.org/simple/',
fallbackMirror: 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple'
}

View File

@@ -1,10 +1,9 @@
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
;(async () => {
if (!isElectron()) return
@@ -56,40 +55,6 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
electronAPI.Config.setWindowStyle(newValue)
}
},
{
id: 'Comfy-Desktop.UV.PythonInstallMirror',
name: 'Python Install Mirror',
tooltip: `Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme.`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn(mirror: string) {
return checkMirrorReachable(
mirror + PYTHON_MIRROR.validationPathSuffix
)
}
}
},
{
id: 'Comfy-Desktop.UV.PypiInstallMirror',
name: 'Pypi Install Mirror',
tooltip: `Default pip install mirror`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn: checkMirrorReachable
}
},
{
id: 'Comfy-Desktop.UV.TorchInstallMirror',
name: 'Torch Install Mirror',
tooltip: `Pip install mirror for pytorch`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn: checkMirrorReachable
}
}
],

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +0,0 @@
import type { IWidget } from '@comfyorg/litegraph'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { api } from '@/scripts/api'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
configure(
loadFolder: 'input' | 'output',
modelWidget: IWidget,
material: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
fov: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
this.setupModelHandling(
modelWidget,
loadFolder,
cameraState,
postModelUpdateFunc
)
this.setupMaterial(material)
this.setupLighting(lightIntensity)
this.setupDirection(upDirection)
this.setupCamera(fov)
this.setupDefaultProperties()
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
cameraState,
postModelUpdateFunc
)
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
}
modelWidget.callback = onModelWidgetUpdate
}
private setupMaterial(material: IWidget) {
material.callback = (value: 'original' | 'normal' | 'wireframe') => {
this.load3d.setMaterialMode(value)
}
this.load3d.setMaterialMode(
material.value as 'original' | 'normal' | 'wireframe'
)
}
private setupLighting(lightIntensity: IWidget) {
lightIntensity.callback = (value: number) => {
this.load3d.setLightIntensity(value)
}
this.load3d.setLightIntensity(lightIntensity.value as number)
}
private setupDirection(upDirection: IWidget) {
upDirection.callback = (
value: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) => {
this.load3d.setUpDirection(value)
}
this.load3d.setUpDirection(
upDirection.value as 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
)
}
private setupCamera(fov: IWidget) {
fov.callback = (value: number) => {
this.load3d.setFOV(value)
}
this.load3d.setFOV(fov.value as number)
}
private setupDefaultProperties() {
const cameraType = this.load3d.loadNodeProperty(
'Camera Type',
'perspective'
)
this.load3d.toggleCamera(cameraType)
const showGrid = this.load3d.loadNodeProperty('Show Grid', true)
this.load3d.toggleGrid(showGrid)
const bgColor = this.load3d.loadNodeProperty('Background Color', '#282828')
this.load3d.setBackgroundColor(bgColor)
}
private createModelUpdateHandler(
loadFolder: 'input' | 'output',
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
const filename = value as string
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(filename),
loadFolder
)
)
await this.load3d.loadModel(modelUrl, filename)
if (postModelUpdateFunc) {
postModelUpdateFunc(this.load3d)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)
} catch (error) {
console.warn('Failed to restore camera state:', error)
}
isFirstLoad = false
}
}
}
}
export default Load3DConfiguration

View File

@@ -1,997 +0,0 @@
import { LGraphNode } from '@comfyorg/litegraph'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { useToastStore } from '@/stores/toastStore'
class Load3d {
scene: THREE.Scene
perspectiveCamera: THREE.PerspectiveCamera
orthographicCamera: THREE.OrthographicCamera
activeCamera: THREE.Camera
renderer: THREE.WebGLRenderer
controls: OrbitControls
gltfLoader: GLTFLoader
objLoader: OBJLoader
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
currentModel: THREE.Object3D | null = null
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null = null
animationFrameId: number | null = null
gridHelper: THREE.GridHelper
lights: THREE.Light[] = []
clock: THREE.Clock
normalMaterial: THREE.MeshNormalMaterial
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]> =
new WeakMap()
materialMode: 'original' | 'normal' | 'wireframe' | 'depth' = 'original'
currentUpDirection: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' =
'original'
originalRotation: THREE.Euler | null = null
viewHelper: ViewHelper = {} as ViewHelper
viewHelperContainer: HTMLDivElement = {} as HTMLDivElement
cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
node: LGraphNode = {} as LGraphNode
bgColorInput: HTMLInputElement = {} as HTMLInputElement
constructor(container: Element | HTMLElement) {
this.scene = new THREE.Scene()
this.perspectiveCamera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
this.perspectiveCamera.position.set(5, 5, 5)
const frustumSize = 10
this.orthographicCamera = new THREE.OrthographicCamera(
-frustumSize / 2,
frustumSize / 2,
frustumSize / 2,
-frustumSize / 2,
0.1,
1000
)
this.orthographicCamera.position.set(5, 5, 5)
this.activeCamera = this.perspectiveCamera
this.perspectiveCamera.lookAt(0, 0, 0)
this.orthographicCamera.lookAt(0, 0, 0)
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
const rendererDomElement: HTMLCanvasElement = this.renderer.domElement
container.appendChild(rendererDomElement)
this.controls = new OrbitControls(
this.activeCamera,
this.renderer.domElement
)
this.controls.enableDamping = true
this.controls.addEventListener('end', () => {
this.storeNodeProperty('Camera Info', this.getCameraState())
})
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader()
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.clock = new THREE.Clock()
this.setupLights()
this.gridHelper = new THREE.GridHelper(10, 10)
this.gridHelper.position.set(0, 0, 0)
this.scene.add(this.gridHelper)
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
this.wireframeMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
this.depthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
this.standardMaterial = this.createSTLMaterial()
this.createViewHelper(container)
this.createGridSwitcher(container)
this.createCameraSwitcher(container)
this.createColorPicker(container)
this.handleResize()
this.startAnimation()
}
setNode(node: LGraphNode) {
this.node = node
}
storeNodeProperty(name: string, value: any) {
if (this.node) {
this.node.properties[name] = value
}
}
loadNodeProperty(name: string, defaultValue: any) {
if (
!this.node ||
!this.node.properties ||
!(name in this.node.properties)
) {
return defaultValue
}
return this.node.properties[name]
}
createViewHelper(container: Element | HTMLElement) {
this.viewHelperContainer = document.createElement('div')
this.viewHelperContainer.style.position = 'absolute'
this.viewHelperContainer.style.bottom = '0'
this.viewHelperContainer.style.left = '0'
this.viewHelperContainer.style.width = '128px'
this.viewHelperContainer.style.height = '128px'
this.viewHelperContainer.addEventListener('pointerup', (event) => {
event.stopPropagation()
this.viewHelper.handleClick(event)
})
this.viewHelperContainer.addEventListener('pointerdown', (event) => {
event.stopPropagation()
})
container.appendChild(this.viewHelperContainer)
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
}
createGridSwitcher(container: Element | HTMLElement) {
this.gridSwitcherContainer = document.createElement('div')
this.gridSwitcherContainer.style.position = 'absolute'
this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方
this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐
this.gridSwitcherContainer.style.width = '20px'
this.gridSwitcherContainer.style.height = '20px'
this.gridSwitcherContainer.style.cursor = 'pointer'
this.gridSwitcherContainer.style.alignItems = 'center'
this.gridSwitcherContainer.style.justifyContent = 'center'
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
const gridIcon = document.createElement('div')
gridIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h18v18H3z"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
`
const updateButtonState = () => {
if (this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
}
updateButtonState()
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
})
this.gridSwitcherContainer.title = 'Toggle Grid'
this.gridSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleGrid(!this.gridHelper.visible)
updateButtonState()
})
this.gridSwitcherContainer.appendChild(gridIcon)
container.appendChild(this.gridSwitcherContainer)
}
createCameraSwitcher(container: Element | HTMLElement) {
this.cameraSwitcherContainer = document.createElement('div')
this.cameraSwitcherContainer.style.position = 'absolute'
this.cameraSwitcherContainer.style.top = '3px'
this.cameraSwitcherContainer.style.left = '3px'
this.cameraSwitcherContainer.style.width = '20px'
this.cameraSwitcherContainer.style.height = '20px'
this.cameraSwitcherContainer.style.cursor = 'pointer'
this.cameraSwitcherContainer.style.alignItems = 'center'
this.cameraSwitcherContainer.style.justifyContent = 'center'
const cameraIcon = document.createElement('div')
cameraIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
<path d="m12 12 4-2.4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.cameraSwitcherContainer.title =
'Switch Camera (Perspective/Orthographic)'
this.cameraSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleCamera()
})
this.cameraSwitcherContainer.appendChild(cameraIcon)
container.appendChild(this.cameraSwitcherContainer)
}
createColorPicker(container: Element | HTMLElement) {
const colorPickerContainer = document.createElement('div')
colorPickerContainer.style.position = 'absolute'
colorPickerContainer.style.top = '53px'
colorPickerContainer.style.left = '3px'
colorPickerContainer.style.width = '20px'
colorPickerContainer.style.height = '20px'
colorPickerContainer.style.cursor = 'pointer'
colorPickerContainer.style.alignItems = 'center'
colorPickerContainer.style.justifyContent = 'center'
colorPickerContainer.title = 'Background Color'
const colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.opacity = '0'
colorInput.style.position = 'absolute'
colorInput.style.width = '100%'
colorInput.style.height = '100%'
colorInput.style.cursor = 'pointer'
const colorIcon = document.createElement('div')
colorIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M12 3v18"/>
<path d="M3 12h18"/>
</svg>
`
colorInput.addEventListener('input', (event) => {
const color = (event.target as HTMLInputElement).value
this.setBackgroundColor(color)
this.storeNodeProperty('Background Color', color)
})
this.bgColorInput = colorInput
colorPickerContainer.appendChild(colorInput)
colorPickerContainer.appendChild(colorIcon)
container.appendChild(colorPickerContainer)
}
setFOV(fov: number) {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.fov = fov
this.perspectiveCamera.updateProjectionMatrix()
this.renderer.render(this.scene, this.activeCamera)
}
}
getCameraState() {
const currentType = this.getCurrentCameraType()
return {
position: this.activeCamera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: currentType
}
}
setCameraState(state: {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
//this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
}
this.controls.update()
}
setUpDirection(
direction: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) {
if (!this.currentModel) return
if (!this.originalRotation && this.currentModel.rotation) {
this.originalRotation = this.currentModel.rotation.clone()
}
this.currentUpDirection = direction
if (this.originalRotation) {
this.currentModel.rotation.copy(this.originalRotation)
}
switch (direction) {
case 'original':
break
case '-x':
this.currentModel.rotation.z = Math.PI / 2
break
case '+x':
this.currentModel.rotation.z = -Math.PI / 2
break
case '-y':
this.currentModel.rotation.x = Math.PI
break
case '+y':
break
case '-z':
this.currentModel.rotation.x = Math.PI / 2
break
case '+z':
this.currentModel.rotation.x = -Math.PI / 2
break
}
this.renderer.render(this.scene, this.activeCamera)
}
setMaterialMode(mode: 'original' | 'normal' | 'wireframe' | 'depth') {
this.materialMode = mode
if (this.currentModel) {
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
this.renderer.outputColorSpace = THREE.SRGBColorSpace
}
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
switch (mode) {
case 'depth':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
const depthMat = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
depthMat.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value:
this.activeCamera instanceof THREE.OrthographicCamera
? 1.0
: 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
depthMat.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
child.material = depthMat
break
case 'normal':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
child.geometry.computeVertexNormals()
break
case 'wireframe':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
break
case 'original':
const originalMaterial = this.originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
} else {
child.material = this.standardMaterial
}
break
}
}
})
this.renderer.render(this.scene, this.activeCamera)
}
}
setupLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
this.scene.add(ambientLight)
this.lights.push(ambientLight)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(0, 10, 10)
this.scene.add(mainLight)
this.lights.push(mainLight)
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -10)
this.scene.add(backLight)
this.lights.push(backLight)
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
leftFillLight.position.set(-10, 0, 0)
this.scene.add(leftFillLight)
this.lights.push(leftFillLight)
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
rightFillLight.position.set(10, 0, 0)
this.scene.add(rightFillLight)
this.lights.push(rightFillLight)
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
this.scene.add(bottomLight)
this.lights.push(bottomLight)
}
toggleCamera(cameraType?: 'perspective' | 'orthographic') {
const oldCamera = this.activeCamera
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const target = this.controls.target.clone()
if (!cameraType) {
this.activeCamera =
oldCamera === this.perspectiveCamera
? this.orthographicCamera
: this.perspectiveCamera
} else {
this.activeCamera =
cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera
if (oldCamera === this.activeCamera) {
return
}
}
this.activeCamera.position.copy(position)
this.activeCamera.rotation.copy(rotation)
if (this.materialMode === 'depth' && oldCamera !== this.activeCamera) {
this.setMaterialMode('depth')
}
this.controls.object = this.activeCamera
this.controls.target.copy(target)
this.controls.update()
this.viewHelper.dispose()
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
this.storeNodeProperty('Camera Type', this.getCurrentCameraType())
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.activeCamera === this.perspectiveCamera
? 'perspective'
: 'orthographic'
}
toggleGrid(showGrid: boolean) {
if (this.gridHelper) {
this.gridHelper.visible = showGrid
this.storeNodeProperty('Show Grid', showGrid)
}
}
setLightIntensity(intensity: number) {
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {
light.intensity = intensity * 0.8
} else if (light === this.lights[2]) {
light.intensity = intensity * 0.5
} else if (light === this.lights[5]) {
light.intensity = intensity * 0.2
} else {
light.intensity = intensity * 0.3
}
} else if (light instanceof THREE.AmbientLight) {
light.intensity = intensity * 0.5
}
})
}
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
if (!this.viewHelper.animating) {
this.storeNodeProperty('Camera Info', this.getCameraState())
}
}
this.renderer.clear()
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.viewHelper.render(this.renderer)
}
animate()
}
clearModel() {
const objectsToRemove: THREE.Object3D[] = []
this.scene.traverse((object) => {
const isEnvironmentObject =
object === this.gridHelper ||
this.lights.includes(object as THREE.Light) ||
object === this.perspectiveCamera ||
object === this.orthographicCamera
if (!isEnvironmentObject) {
objectsToRemove.push(object)
}
})
objectsToRemove.forEach((obj) => {
if (obj.parent && obj.parent !== this.scene) {
obj.parent.remove(obj)
} else {
this.scene.remove(obj)
}
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach((material) => material.dispose())
} else {
obj.material?.dispose()
}
}
})
this.resetScene()
}
protected resetScene() {
this.currentModel = null
this.originalRotation = null
const defaultDistance = 10
this.perspectiveCamera.position.set(
defaultDistance,
defaultDistance,
defaultDistance
)
this.orthographicCamera.position.set(
defaultDistance,
defaultDistance,
defaultDistance
)
this.perspectiveCamera.lookAt(0, 0, 0)
this.orthographicCamera.lookAt(0, 0, 0)
const frustumSize = 10
const aspect =
this.renderer.domElement.width / this.renderer.domElement.height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.perspectiveCamera.updateProjectionMatrix()
this.orthographicCamera.updateProjectionMatrix()
this.controls.target.set(0, 0, 0)
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.materialMode = 'original'
this.originalMaterials = new WeakMap()
this.renderer.outputColorSpace = THREE.SRGBColorSpace
}
remove() {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
this.controls.dispose()
this.viewHelper.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
this.scene.clear()
}
protected async loadModelInternal(
url: string,
fileExtension: string
): Promise<THREE.Object3D | null> {
let model: THREE.Object3D | null = null
switch (fileExtension) {
case 'stl':
const geometry = await this.stlLoader.loadAsync(url)
this.originalModel = geometry
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, this.standardMaterial)
const group = new THREE.Group()
group.add(mesh)
model = group
break
case 'fbx':
const fbxModel = await this.fbxLoader.loadAsync(url)
this.originalModel = fbxModel
model = fbxModel
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.originalMaterials.set(child, child.material)
}
})
break
case 'obj':
if (this.materialMode === 'original') {
const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1')
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()
this.objLoader.setMaterials(materials)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
model = await this.objLoader.loadAsync(url)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.originalMaterials.set(child, child.material)
}
})
break
case 'gltf':
case 'glb':
const gltf = await this.gltfLoader.loadAsync(url)
this.originalModel = gltf
model = gltf.scene
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.originalMaterials.set(child, child.material)
}
})
break
}
return model
}
async loadModel(url: string, originalFileName?: string) {
try {
this.clearModel()
let fileExtension: string | undefined
if (originalFileName) {
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
}
if (!fileExtension) {
useToastStore().addAlert('Could not determine file type')
return
}
let model = await this.loadModelInternal(url, fileExtension)
if (model) {
this.currentModel = model
await this.setupModel(model)
}
} catch (error) {
console.error('Error loading model:', error)
}
}
protected async setupModel(model: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
model.scale.multiplyScalar(scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(model)
if (this.materialMode !== 'original') {
this.setMaterialMode(this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}
await this.setupCamera(size)
}
protected async setupCamera(size: THREE.Vector3) {
const distance = Math.max(size.x, size.z) * 2
const height = size.y * 2
this.perspectiveCamera.position.set(distance, height, distance)
this.orthographicCamera.position.set(distance, height, distance)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = Math.max(size.x, size.y, size.z) * 2
const aspect =
this.renderer.domElement.width / this.renderer.domElement.height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.lookAt(0, size.y / 2, 0)
this.orthographicCamera.updateProjectionMatrix()
}
this.controls.target.set(0, size.y / 2, 0)
this.controls.update()
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1
this.handleResize()
}
handleResize() {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const width = parentElement?.clientWidth
const height = parentElement?.clientHeight
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = 10
const aspect = width / height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.updateProjectionMatrix()
}
this.renderer.setSize(width, height)
}
animate = () => {
requestAnimationFrame(this.animate)
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
}
captureScene(
width: number,
height: number
): Promise<{ scene: string; mask: string }> {
return new Promise(async (resolve, reject) => {
try {
const originalWidth = this.renderer.domElement.width
const originalHeight = this.renderer.domElement.height
const originalClearColor = this.renderer.getClearColor(
new THREE.Color()
)
const originalClearAlpha = this.renderer.getClearAlpha()
this.renderer.setSize(width, height)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = 10
const aspect = width / height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.updateProjectionMatrix()
}
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const sceneData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setClearColor(0x000000, 0)
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const maskData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
this.renderer.setSize(originalWidth, originalHeight)
this.handleResize()
resolve({ scene: sceneData, mask: maskData })
} catch (error) {
reject(error)
}
})
}
createSTLMaterial() {
return new THREE.MeshStandardMaterial({
color: 0x808080,
metalness: 0.1,
roughness: 0.8,
flatShading: false,
side: THREE.DoubleSide
})
}
setBackgroundColor(color: string) {
this.renderer.setClearColor(new THREE.Color(color))
this.renderer.render(this.scene, this.activeCamera)
if (this.bgColorInput) {
this.bgColorInput.value = color
}
}
}
export default Load3d

View File

@@ -1,359 +0,0 @@
import * as THREE from 'three'
import Load3d from '@/extensions/core/load3d/Load3d'
class Load3dAnimation extends Load3d {
currentAnimation: THREE.AnimationMixer | null = null
animationActions: THREE.AnimationAction[] = []
animationClips: THREE.AnimationClip[] = []
selectedAnimationIndex: number = 0
isAnimationPlaying: boolean = false
animationSpeed: number = 1.0
playPauseContainer: HTMLDivElement = {} as HTMLDivElement
animationSelect: HTMLSelectElement = {} as HTMLSelectElement
speedSelect: HTMLSelectElement = {} as HTMLSelectElement
constructor(container: Element | HTMLElement) {
super(container)
this.createPlayPauseButton(container)
this.createAnimationList(container)
this.createSpeedSelect(container)
}
createAnimationList(container: Element | HTMLElement) {
this.animationSelect = document.createElement('select')
Object.assign(this.animationSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(15px)',
width: '90px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
this.animationSelect.addEventListener('mouseenter', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.animationSelect.addEventListener('mouseleave', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.animationSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
this.updateSelectedAnimation(select.selectedIndex)
})
container.appendChild(this.animationSelect)
}
updateAnimationList() {
this.animationSelect.innerHTML = ''
this.animationClips.forEach((clip, index) => {
const option = document.createElement('option')
option.value = index.toString()
option.text = clip.name || `Animation ${index + 1}`
option.selected = index === this.selectedAnimationIndex
this.animationSelect.appendChild(option)
})
}
createPlayPauseButton(container: Element | HTMLElement) {
this.playPauseContainer = document.createElement('div')
this.playPauseContainer.style.position = 'absolute'
this.playPauseContainer.style.top = '3px'
this.playPauseContainer.style.left = '50%'
this.playPauseContainer.style.transform = 'translateX(-50%)'
this.playPauseContainer.style.width = '20px'
this.playPauseContainer.style.height = '20px'
this.playPauseContainer.style.cursor = 'pointer'
this.playPauseContainer.style.alignItems = 'center'
this.playPauseContainer.style.justifyContent = 'center'
const updateButtonState = () => {
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = `
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
`
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = `
<path d="M8 5v14l11-7z"/>
`
this.playPauseContainer.title = 'Play Animation'
}
}
}
const playIcon = document.createElement('div')
playIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M8 5v14l11-7z"/>
</svg>
`
this.playPauseContainer.addEventListener('mouseenter', () => {
this.playPauseContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.playPauseContainer.addEventListener('mouseleave', () => {
this.playPauseContainer.style.backgroundColor = 'transparent'
})
this.playPauseContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleAnimation()
updateButtonState()
})
this.playPauseContainer.appendChild(playIcon)
container.appendChild(this.playPauseContainer)
this.playPauseContainer.style.display = 'none'
}
createSpeedSelect(container: Element | HTMLElement) {
this.speedSelect = document.createElement('select')
Object.assign(this.speedSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(-75px)',
width: '60px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
const speeds = [0.1, 0.5, 1, 1.5, 2]
speeds.forEach((speed) => {
const option = document.createElement('option')
option.value = speed.toString()
option.text = `${speed}x`
option.selected = speed === 1
this.speedSelect.appendChild(option)
})
this.speedSelect.addEventListener('mouseenter', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.speedSelect.addEventListener('mouseleave', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.speedSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
const newSpeed = parseFloat(select.value)
this.setAnimationSpeed(newSpeed)
})
container.appendChild(this.speedSelect)
}
protected async setupModel(model: THREE.Object3D) {
await super.setupModel(model)
if (this.currentAnimation) {
this.currentAnimation.stopAllAction()
this.animationActions = []
}
let animations: THREE.AnimationClip[] = []
if (model.animations?.length > 0) {
animations = model.animations
} else if (this.originalModel && 'animations' in this.originalModel) {
animations = (
this.originalModel as unknown as { animations: THREE.AnimationClip[] }
).animations
}
if (animations.length > 0) {
this.animationClips = animations
if (model.type === 'Scene') {
this.currentAnimation = new THREE.AnimationMixer(model)
} else {
this.currentAnimation = new THREE.AnimationMixer(this.currentModel!)
}
if (this.animationClips.length > 0) {
this.updateSelectedAnimation(0)
}
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
} else {
this.playPauseContainer.style.display = 'none'
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
this.animationSelect.style.display = 'block'
this.speedSelect.style.display = 'block'
this.updateAnimationList()
} else {
this.playPauseContainer.style.display = 'none'
this.animationSelect.style.display = 'none'
this.speedSelect.style.display = 'none'
}
}
setAnimationSpeed(speed: number) {
this.animationSpeed = speed
this.animationActions.forEach((action) => {
action.setEffectiveTimeScale(speed)
})
}
updateSelectedAnimation(index: number) {
if (
!this.currentAnimation ||
!this.animationClips ||
index >= this.animationClips.length
) {
console.warn('Invalid animation update request')
return
}
this.animationActions.forEach((action) => {
action.stop()
})
this.currentAnimation.stopAllAction()
this.animationActions = []
this.selectedAnimationIndex = index
const clip = this.animationClips[index]
const action = this.currentAnimation.clipAction(clip)
action.setEffectiveTimeScale(this.animationSpeed)
action.reset()
action.clampWhenFinished = false
action.loop = THREE.LoopRepeat
if (this.isAnimationPlaying) {
action.play()
} else {
action.play()
action.paused = true
}
this.animationActions = [action]
this.updateAnimationList()
}
clearModel() {
if (this.currentAnimation) {
this.animationActions.forEach((action) => {
action.stop()
})
this.currentAnimation = null
}
this.animationActions = []
this.animationClips = []
this.selectedAnimationIndex = 0
this.isAnimationPlaying = false
this.animationSpeed = 1.0
super.clearModel()
if (this.animationSelect) {
this.animationSelect.style.display = 'none'
this.animationSelect.innerHTML = ''
}
if (this.speedSelect) {
this.speedSelect.style.display = 'none'
this.speedSelect.value = '1'
}
}
getAnimationNames(): string[] {
return this.animationClips.map((clip, index) => {
return clip.name || `Animation ${index + 1}`
})
}
toggleAnimation(play?: boolean) {
if (!this.currentAnimation || this.animationActions.length === 0) {
console.warn('No animation to toggle')
return
}
this.isAnimationPlaying = play ?? !this.isAnimationPlaying
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = '<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>'
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = '<path d="M8 5v14l11-7z"/>'
this.playPauseContainer.title = 'Play Animation'
}
}
this.animationActions.forEach((action) => {
if (this.isAnimationPlaying) {
action.paused = false
if (action.time === 0 || action.time === action.getClip().duration) {
action.reset()
}
} else {
action.paused = true
}
})
}
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
}
this.controls.update()
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
if (!this.viewHelper.animating) {
this.storeNodeProperty('Camera Info', this.getCameraState())
}
}
this.viewHelper.render(this.renderer)
}
animate()
}
}
export default Load3dAnimation

View File

@@ -1,122 +0,0 @@
import Load3d from '@/extensions/core/load3d/Load3d'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useToastStore } from '@/stores/toastStore'
class Load3dUtils {
static async uploadTempImage(imageData: string, prefix: string) {
const blob = await fetch(imageData).then((r) => r.blob())
const name = `${prefix}_${Date.now()}.png`
const file = new File([blob], name)
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'threed')
body.append('type', 'temp')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
const err = `Error uploading temp image: ${resp.status} - ${resp.statusText}`
useToastStore().addAlert(err)
throw new Error(err)
}
return await resp.json()
}
static async uploadFile(
load3d: Load3d,
file: File,
fileInput?: HTMLInputElement
) {
let uploadPath
try {
const body = new FormData()
body.append('image', file)
body.append('subfolder', '3d')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
uploadPath = path
const modelUrl = api.apiURL(
this.getResourceURL(...this.splitFilePath(path), 'input')
)
await load3d.loadModel(modelUrl, file.name)
const fileExt = file.name.split('.').pop()?.toLowerCase()
if (fileExt === 'obj' && fileInput?.files) {
try {
const mtlFile = Array.from(fileInput.files).find((f) =>
f.name.toLowerCase().endsWith('.mtl')
)
if (mtlFile) {
const mtlFormData = new FormData()
mtlFormData.append('image', mtlFile)
mtlFormData.append('subfolder', '3d')
await api.fetchApi('/upload/image', {
method: 'POST',
body: mtlFormData
})
}
} catch (mtlError) {
console.warn('Failed to upload MTL file:', mtlError)
}
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
console.error('Upload error:', error)
useToastStore().addAlert(
error instanceof Error ? error.message : 'Upload failed'
)
}
return uploadPath
}
static splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}
static getResourceURL(
subfolder: string,
filename: string,
type: string = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
}
export default Load3dUtils

View File

@@ -782,27 +782,7 @@ app.registerExtension({
? origGetExtraMenuOptions.apply(this, arguments)
: undefined
const getPointerCanvasPos = () => {
const pos = this.graph?.list_of_graphcanvas?.at(0)?.graph_mouse
return pos ? { canvasX: pos[0], canvasY: pos[1] } : undefined
}
if (this.widgets) {
const { canvasX, canvasY } = getPointerCanvasPos()
const widget = this.getWidgetOnPos(canvasX, canvasY)
// @ts-expect-error custom widget type
if (widget && widget.type !== CONVERTED_TYPE) {
const config = getConfig.call(this, widget.name) ?? [
widget.type,
widget.options || {}
]
if (isConvertibleWidget(widget, config)) {
options.push({
content: `Convert ${widget.name} to input`,
callback: () => convertToInput(this, widget, config) && false
})
}
}
let toInput = []
let toWidget = []
for (const w of this.widgets) {

View File

@@ -1,77 +0,0 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
import { Ref } from 'vue'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { ComfyModelDef } from '@/stores/modelStore'
import { ModelNodeProvider } from '@/stores/modelToNodeStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
const modelToNodeStore = useModelToNodeStore()
const litegraphService = useLitegraphService()
usePragmaticDroppable(() => canvasRef.value, {
getDropEffect: (args): Exclude<DataTransfer['dropEffect'], 'none'> =>
args.source.data.type === 'tree-explorer-node' ? 'copy' : 'move',
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX,
loc.clientY + LiteGraph.NODE_TITLE_HEIGHT
])
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets?.find(
(widget) => widget.name === targetProvider?.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
}

View File

@@ -1,27 +0,0 @@
// @ts-strict-ignore
import {
ContextMenu,
DragAndScale,
LGraph,
LGraphBadge,
LGraphCanvas,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph
} from '@comfyorg/litegraph'
/**
* Assign all properties of LiteGraph to window to make it backward compatible.
*/
export const useGlobalLitegraph = () => {
window['LiteGraph'] = LiteGraph
window['LGraph'] = LGraph
window['LLink'] = LLink
window['LGraphNode'] = LGraphNode
window['LGraphGroup'] = LGraphGroup
window['DragAndScale'] = DragAndScale
window['LGraphCanvas'] = LGraphCanvas
window['ContextMenu'] = ContextMenu
window['LGraphBadge'] = LGraphBadge
}

View File

@@ -1,130 +0,0 @@
import { computed, watch, watchEffect } from 'vue'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { useWorkflowService } from '@/services/workflowService'
import { useModelStore } from '@/stores/modelStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
export function useWorkflowPersistence() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const persistCurrentWorkflow = () => {
const workflow = JSON.stringify(comfyApp.serializeGraph())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
const loadWorkflowFromStorage = async (
json: string | null,
workflowName: string | null
) => {
if (!json) return false
const workflow = JSON.parse(json)
await comfyApp.loadGraphData(workflow, true, true, workflowName)
return true
}
const loadPreviousWorkflowFromStorage = async () => {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const clientId = api.initialClientId ?? api.clientId
// Try loading from session storage first
if (clientId) {
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
return true
}
}
// Fall back to local storage
const localWorkflow = localStorage.getItem('workflow')
return await loadWorkflowFromStorage(localWorkflow, workflowName)
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useModelStore().loadModelFolders()
await useWorkflowService().loadTutorialWorkflow()
} else {
await comfyApp.loadGraphData()
}
}
const restorePreviousWorkflow = async () => {
try {
const restored = await loadPreviousWorkflowFromStorage()
if (!restored) {
await loadDefaultWorkflow()
}
} catch (err) {
console.error('Error loading previous workflow', err)
await loadDefaultWorkflow()
}
}
// Setup watchers
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.key)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
})
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
return { paths, activeIndex }
}
)
// Get storage values before setting watchers
const storedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
watch(restoreState, ({ paths, activeIndex }) => {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
})
const restoreWorkflowTabsState = () => {
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable) {
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
}
return {
restorePreviousWorkflow,
restoreWorkflowTabsState
}
}

View File

@@ -212,13 +212,7 @@
"customNodeConfigurations": "Custom node configurations"
},
"viewFullPolicy": "View full policy"
},
"pythonMirrorPlaceholder": "Enter Python mirror URL",
"pypiMirrorPlaceholder": "Enter PyPI mirror URL",
"checkingMirrors": "Checking network access to python mirrors...",
"mirrorsReachable": "Network access to python mirrors is good",
"mirrorsUnreachable": "Network access to some python mirrors is bad",
"mirrorSettings": "Mirror Settings"
}
},
"customNodes": "Custom Nodes",
"customNodesDescription": "Reinstall custom nodes from existing ComfyUI installations.",
@@ -477,9 +471,7 @@
"Server-Config": "Server-Config",
"About": "About",
"EditTokenWeight": "Edit Token Weight",
"CustomColorPalettes": "Custom Color Palettes",
"UV": "UV",
"ContextMenu": "Context Menu"
"CustomColorPalettes": "Custom Color Palettes"
},
"serverConfigItems": {
"listen": {
@@ -716,10 +708,5 @@
"taskFailed": "Task failed to run.",
"defaultDescription": "An error occurred while running a maintenance task."
}
},
"missingModelsDialog": {
"doNotAskAgain": "Don't show this again",
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
}
}

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "Send anonymous usage metrics"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror",
"tooltip": "Default pip install mirror"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror",
"tooltip": "Pip install mirror for pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "Window Style",
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
@@ -321,9 +309,6 @@
"name": "Maxium FPS",
"tooltip": "The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Scale node combo widget menus (lists) when zoomed in"
},
"pysssss_SnapToGrid": {
"name": "Always snap to grid"
}

View File

@@ -220,7 +220,6 @@
"allowMetricsDescription": "Aidez à améliorer ComfyUI en envoyant des métriques d'utilisation anonymes. Aucune information personnelle ou contenu de flux de travail ne sera collecté.",
"autoUpdate": "Mises à jour automatiques",
"autoUpdateDescription": "Téléchargez et installez automatiquement les mises à jour lorsqu'elles deviennent disponibles. Vous serez toujours informé avant l'installation des mises à jour.",
"checkingMirrors": "Vérification de l'accès réseau aux miroirs python...",
"dataCollectionDialog": {
"collect": {
"errorReports": "Message d'erreur et trace de la pile",
@@ -240,12 +239,7 @@
},
"errorUpdatingConsent": "Erreur de mise à jour du consentement",
"errorUpdatingConsentDetail": "Échec de la mise à jour des paramètres de consentement aux métriques",
"learnMoreAboutData": "En savoir plus sur la collecte de données",
"mirrorSettings": "Paramètres du Miroir",
"mirrorsReachable": "L'accès réseau aux miroirs python est bon",
"mirrorsUnreachable": "L'accès au réseau à certains miroirs python est mauvais",
"pypiMirrorPlaceholder": "Entrez l'URL du miroir PyPI",
"pythonMirrorPlaceholder": "Entrez l'URL du miroir Python"
"learnMoreAboutData": "En savoir plus sur la collecte de données"
},
"systemLocations": "Emplacements système",
"unhandledError": "Erreur inconnue",
@@ -376,11 +370,6 @@
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
"missingModels": "Modèles manquants",
"missingModelsMessage": "Lors du chargement du graphique, les modèles suivants n'ont pas été trouvés"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "modèles_3d",
@@ -609,7 +598,6 @@
"ColorPalette": "Palette de Couleurs",
"Comfy": "Confort",
"Comfy-Desktop": "Comfy-Desktop",
"ContextMenu": "Menu Contextuel",
"CustomColorPalettes": "Palettes de Couleurs Personnalisées",
"DevMode": "Mode Développeur",
"EditTokenWeight": "Modifier le Poids du Jeton",
@@ -640,7 +628,6 @@
"Settings": "Paramètres",
"Sidebar": "Barre Latérale",
"Tree Explorer": "Explorateur d'Arbre",
"UV": "UV",
"Validation": "Validation",
"Window": "Fenêtre",
"Workflow": "Flux de Travail"

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "Envoyer des métriques d'utilisation anonymes"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Miroir d'installation Pypi",
"tooltip": "Miroir d'installation pip par défaut"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Miroir d'installation Python",
"tooltip": "Les installations Python gérées sont téléchargées depuis le projet Astral python-build-standalone. Cette variable peut être définie sur une URL de miroir pour utiliser une source différente pour les installations Python. L'URL fournie remplacera https://github.com/astral-sh/python-build-standalone/releases/download dans, par exemple, https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Les distributions peuvent être lues à partir d'un répertoire local en utilisant le schéma d'URL file://."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Miroir d'installation Torch",
"tooltip": "Miroir d'installation Pip pour pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "Style de fenêtre",
"options": {
@@ -321,9 +309,6 @@
"name": "FPS maximum",
"tooltip": "Le nombre maximum d'images par seconde que le canevas est autorisé à rendre. Limite l'utilisation du GPU au détriment de la fluidité. Si 0, le taux de rafraîchissement de l'écran est utilisé. Par défaut : 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Mise à l'échelle des menus de widgets combinés de nœuds (listes) lors du zoom"
},
"pysssss_SnapToGrid": {
"name": "Toujours aligner sur la grille"
}

View File

@@ -220,7 +220,6 @@
"allowMetricsDescription": "匿名の使用状況メトリクスを送信してComfyUIを改善します。個人情報やワークフローの内容は収集されません。",
"autoUpdate": "自動更新",
"autoUpdateDescription": "更新が利用可能になると、自動的にダウンロードおよびインストールを行います。インストール前に通知が表示されます。",
"checkingMirrors": "Pythonミラーへのネットワークアクセスを確認中...",
"dataCollectionDialog": {
"collect": {
"errorReports": "エラーメッセージとスタックトレース",
@@ -240,12 +239,7 @@
},
"errorUpdatingConsent": "同意の更新エラー",
"errorUpdatingConsentDetail": "メトリクスの同意設定の更新に失敗しました",
"learnMoreAboutData": "データ収集の詳細を見る",
"mirrorSettings": "ミラー設定",
"mirrorsReachable": "Pythonミラーへのネットワークアクセスは良好です",
"mirrorsUnreachable": "一部のpythonミラーへのネットワークアクセスが悪い",
"pypiMirrorPlaceholder": "PyPIミラーURLを入力してください",
"pythonMirrorPlaceholder": "PythonミラーURLを入力してください"
"learnMoreAboutData": "データ収集の詳細を見る"
},
"systemLocations": "システムの場所",
"unhandledError": "未知のエラー",
@@ -376,11 +370,6 @@
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
"missingModels": "モデルが見つかりません",
"missingModelsMessage": "グラフを読み込む際に、次のモデルが見つかりませんでした"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3Dモデル",
@@ -609,7 +598,6 @@
"ColorPalette": "カラーパレット",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfyデスクトップ",
"ContextMenu": "コンテキストメニュー",
"CustomColorPalettes": "カスタムカラーパレット",
"DevMode": "開発モード",
"EditTokenWeight": "トークンの重みを編集",
@@ -640,7 +628,6 @@
"Settings": "設定",
"Sidebar": "サイドバー",
"Tree Explorer": "ツリーエクスプローラー",
"UV": "UV",
"Validation": "検証",
"Window": "ウィンドウ",
"Workflow": "ワークフロー"

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "匿名の使用統計を送信する"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi インストールミラー",
"tooltip": "デフォルトの pip インストールミラー"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror",
"tooltip": "管理されたPythonのインストールは、Astral python-build-standaloneプロジェクトからダウンロードされます。この変数は、Pythonのインストールのための異なるソースを使用するためのミラーURLに設定することができます。提供されたURLは、例えば、https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gzの中でhttps://github.com/astral-sh/python-build-standalone/releases/downloadを置き換えます。ディストリビューションは、file:// URLスキームを使用してローカルディレクトリから読み取ることができます。"
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror",
"tooltip": "pytorchのpipインストールミラー"
},
"Comfy-Desktop_WindowStyle": {
"name": "ウィンドウスタイル",
"options": {
@@ -321,9 +309,6 @@
"name": "最大FPS",
"tooltip": "キャンバスがレンダリングできる最大フレーム数です。スムーズさの代わりにGPU使用量を制限します。0の場合、画面のリフレッシュレートが使用されます。デフォルト0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "ズームイン時にノードコンボウィジェットメニュー(リスト)をスケーリングする"
},
"pysssss_SnapToGrid": {
"name": "常にグリッドにスナップ"
}

View File

@@ -180,11 +180,11 @@
"cpuMode": "CPU 모드",
"cpuModeDescription": "CPU 모드는 개발자와 드문 경우에만 사용됩니다.",
"cpuModeDescription2": "이것이 필요한지 확실하지 않다면, 이 상자를 무시하고 위에서 GPU를 선택하세요.",
"customComfyNeedsPython": "Python이 설정되지 않으면 ComfyUI가 작동하지 않습니다",
"customComfyNeedsPython": "파이썬이 설정되지 않으면 ComfyUI가 작동하지 않습니다",
"customInstallRequirements": "모든 요구 사항과 종속성 설치 (예: 사용자 정의 torch)",
"customManualVenv": "Python venv를 수동으로 구성",
"customManualVenv": "파이썬 venv를 수동으로 구성",
"customMayNotWork": "이것은 전혀 지원되지 않으며, 작동하지 않을 수 있습니다",
"customSkipsPython": "이 옵션은 일반 Python 설정을 건너뜁니다.",
"customSkipsPython": "이 옵션은 일반 파이썬 설정을 건너뜁니다.",
"enableCpuMode": "CPU 모드 활성화",
"mpsDescription": "Apple Metal Performance Shaders는 pytorch nightly를 사용하여 지원됩니다.",
"nvidiaDescription": "NVIDIA 장치는 pytorch CUDA 빌드를 사용하여 직접 지원됩니다.",
@@ -220,7 +220,6 @@
"allowMetricsDescription": "익명의 사용 통계를 보내 ComfyUI를 개선하는 데 도움을 줍니다. 개인 정보나 워크플로 내용은 수집되지 않습니다.",
"autoUpdate": "자동 업데이트",
"autoUpdateDescription": "업데이트가 가능해지면 자동으로 다운로드하고 설치합니다. 업데이트가 설치되기 전에 항상 알림을 받습니다.",
"checkingMirrors": "Python 미러에 대한 네트워크 액세스 확인 중...",
"dataCollectionDialog": {
"collect": {
"errorReports": "오류 메시지 및 스택 추적",
@@ -240,12 +239,7 @@
},
"errorUpdatingConsent": "데이터 수집 동의 설정 업데이트 오류",
"errorUpdatingConsentDetail": "데이터 수집 동의 설정 업데이트에 실패했습니다",
"learnMoreAboutData": "데이터 수집에 대해 더 알아보기",
"mirrorSettings": "미러 URL 설정",
"mirrorsReachable": "Python 미러에 대한 네트워크 액세스가 좋습니다",
"mirrorsUnreachable": "일부 Python 미러에 대한 네트워크 접근이 나쁩니다",
"pypiMirrorPlaceholder": "PyPI 미러 URL 입력",
"pythonMirrorPlaceholder": "Python 미러 URL 입력"
"learnMoreAboutData": "데이터 수집에 대해 더 알아보기"
},
"systemLocations": "시스템 위치",
"unhandledError": "알 수 없는 오류",
@@ -376,11 +370,6 @@
"Zoom In": "확대",
"Zoom Out": "축소"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
"missingModels": "모델이 없습니다",
"missingModelsMessage": "그래프를 로드할 때 다음 모델을 찾을 수 없었습니다"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3D 모델",
@@ -593,7 +582,7 @@
"process": {
"error": "ComfyUI Desktop을 시작할 수 없습니다",
"initial-state": "로딩 중...",
"python-setup": "Python 환경 설정 중...",
"python-setup": "파이썬 환경 설정 중...",
"ready": "마무리 중...",
"starting-server": "ComfyUI 서버 시작 중..."
},
@@ -609,7 +598,6 @@
"ColorPalette": "색상 팔레트",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfy-Desktop",
"ContextMenu": "컨텍스트 메뉴",
"CustomColorPalettes": "사용자 정의 색상 팔레트",
"DevMode": "개발자 모드",
"EditTokenWeight": "토큰 가중치 편집",
@@ -640,7 +628,6 @@
"Settings": "설정",
"Sidebar": "사이드바",
"Tree Explorer": "트리 탐색기",
"UV": "UV",
"Validation": "검증",
"Window": "창",
"Workflow": "워크플로"

View File

@@ -672,7 +672,7 @@
}
},
"ConditioningStableAudio": {
"display_name": "Stable Audio 조건 설정",
"display_name": "ConditioningStableAudio",
"inputs": {
"negative": {
"name": "부정 조건"
@@ -716,7 +716,7 @@
}
},
"ConditioningZeroOut": {
"display_name": "조건 (0으로 출력)",
"display_name": "조건 (제로 아웃)",
"inputs": {
"conditioning": {
"name": "조건"
@@ -908,7 +908,7 @@
}
},
"CreateHookKeyframesFromFloats": {
"display_name": "실수로 후크 키프레임 생성",
"display_name": "부동 소수점으로 후크 키프레임 생성",
"inputs": {
"end_percent": {
"name": "종료 퍼센트"
@@ -1163,11 +1163,11 @@
}
},
"DevToolsObjectPatchNode": {
"description": "객체 패치를 적용하는 노드",
"display_name": "객체 패치 노드",
"description": "오브젝트 패치를 적용하는 노드",
"display_name": "오브젝트 패치 노드",
"inputs": {
"dummy_float": {
"name": "더미 실수"
"name": "더미 플로트"
},
"model": {
"name": "모델"
@@ -1257,7 +1257,7 @@
}
},
"EmptyCosmosLatentVideo": {
"display_name": "빈 잠재 비디오 (Cosmos)",
"display_name": "EmptyCosmosLatentVideo",
"inputs": {
"batch_size": {
"name": "배치 크기"
@@ -1940,7 +1940,7 @@
"tooltip": "Classifier-Free Guidance 스케일은 창의성과 프롬프트 준수를 균형 있게 조절합니다. 값이 높을수록 프롬프트와 더 밀접하게 일치하는 이미지가 생성되지만, 너무 높은 값은 품질에 부정적인 영향을 미칠 수 있습니다."
},
"denoise": {
"name": "노이즈 제거",
"name": "노이즈_제거",
"tooltip": "적용되는 노이즈 제거의 양으로, 낮은 값은 초기 이미지의 구조를 유지하여 이미지 간 샘플링을 가능하게 합니다."
},
"latent_image": {
@@ -2581,7 +2581,7 @@
"tooltip": "LoRA가 적용될 확산 모델입니다."
},
"strength_clip": {
"name": "clip 강도",
"name": "클립 강도",
"tooltip": "CLIP 모델을 적용하는 강도입니다. 이 값은 음수가 될 수 있습니다."
},
"strength_model": {
@@ -4404,7 +4404,7 @@
}
},
"RescaleCFG": {
"display_name": "CFG 리스케일",
"display_name": "CFG 크기 재조정",
"inputs": {
"model": {
"name": "모델"
@@ -4855,7 +4855,7 @@
"display_name": "CLIP 후크 설정",
"inputs": {
"apply_to_conds": {
"name": "조건에 적용"
"name": "조건에_적용"
},
"clip": {
"name": "clip"
@@ -4864,7 +4864,7 @@
"name": "후크"
},
"schedule_clip": {
"name": "clip 스케쥴 사용"
"name": "스케줄_클립"
}
}
},
@@ -4913,8 +4913,8 @@
}
},
"SkipLayerGuidanceDiT": {
"description": "모든 DiT 모델에서 사용할 수 있는 '레이어 건너뛰기 가이던스' 노드의 범용 버전입니다.",
"display_name": "레이어 건너뛰기 가이던스 (DiT)",
"description": "모든 DiT 모델에서 사용할 수 있는 SkipLayerGuidance 노드의 범용 버전입니다.",
"display_name": "SkipLayerGuidanceDiT",
"inputs": {
"double_layers": {
"name": "double_layers"
@@ -4926,13 +4926,13 @@
"name": "모델"
},
"rescaling_scale": {
"name": "리스케일 크기"
"name": "크기 재조정 크기"
},
"scale": {
"name": "크기"
},
"single_layers": {
"name": "single_layers"
"name": "단일_레이어"
},
"start_percent": {
"name": "시작 퍼센트"
@@ -4940,14 +4940,14 @@
}
},
"SkipLayerGuidanceSD3": {
"description": "SD3 용 레이어 건너뛰기 가이던스 노드입니다.",
"display_name": "레이어 건너뛰기 가이던스 (SD3)",
"description": "모든 DiT 모델에서 사용할 수 있는 SkipLayerGuidance 노드의 범용 버전입니다.",
"display_name": "SkipLayerGuidanceSD3",
"inputs": {
"end_percent": {
"name": "종료 퍼센트"
},
"layers": {
"name": "layers"
"name": "레이어"
},
"model": {
"name": "모델"

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "익명 사용 통계 보내기"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "PyPI 설치 미러",
"tooltip": "기본 pip 설치 미러"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python 설치 미러",
"tooltip": "관리되는 Python 설치파일은 Astral python-build-standalone 프로젝트에서 다운로드됩니다. 이 변수는 Python 설치파일의 다른 출처를 사용하기 위해 미러 URL로 설정할 수 있습니다. 예를 들어, 제공된 URL은 https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz에서 https://github.com/astral-sh/python-build-standalone/releases/download 를 대체합니다. 배포판은 file:// URL 스키마를 사용하여 로컬 디렉토리에서 읽을 수 있습니다."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "torch 설치 미러",
"tooltip": "pytorch를 위한 pip 설치 미러"
},
"Comfy-Desktop_WindowStyle": {
"name": "창 스타일",
"options": {
@@ -321,9 +309,6 @@
"name": "최대 FPS",
"tooltip": "캔버스가 렌더링할 수 있는 최대 프레임 수입니다. 부드럽게 동작하도록 GPU 사용률을 제한 합니다. 0이면 화면 주사율로 작동 합니다. 기본값: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "확대시 노드 콤보 위젯 메뉴 (목록) 스케일링"
},
"pysssss_SnapToGrid": {
"name": "항상 그리드에 스냅"
}

View File

@@ -220,7 +220,6 @@
"allowMetricsDescription": "Помогите улучшить ComfyUI, отправляя анонимные метрики использования. Личная информация или содержание рабочего процесса не будут собираться.",
"autoUpdate": "Автоматические обновления",
"autoUpdateDescription": "Автоматически загружать и устанавливать обновления, когда они становятся доступными. Вы всегда будете уведомлены перед установкой обновлений.",
"checkingMirrors": "Проверка доступа к зеркалам python по сети...",
"dataCollectionDialog": {
"collect": {
"errorReports": "Сообщение об ошибке и трассировка стека",
@@ -240,12 +239,7 @@
},
"errorUpdatingConsent": "Ошибка обновления согласия",
"errorUpdatingConsentDetail": "Не удалось обновить настройки согласия на метрики",
"learnMoreAboutData": "Узнать больше о сборе данных",
"mirrorSettings": "Настройки зеркала",
"mirrorsReachable": "Сетевой доступ к зеркалам python хороший",
"mirrorsUnreachable": "Сетевой доступ к некоторым зеркалам python плохой",
"pypiMirrorPlaceholder": "Введите URL-зеркало PyPI",
"pythonMirrorPlaceholder": "Введите URL-зеркало Python"
"learnMoreAboutData": "Узнать больше о сборе данных"
},
"systemLocations": "Системные места",
"unhandledError": "Неизвестная ошибка",
@@ -376,11 +370,6 @@
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
"missingModels": "Отсутствующие модели",
"missingModelsMessage": "При загрузке графа следующие модели не были найдены"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3d_модели",
@@ -609,7 +598,6 @@
"ColorPalette": "Цветовая палитра",
"Comfy": "Comfy",
"Comfy-Desktop": "Десктопный Comfy",
"ContextMenu": "Контекстное меню",
"CustomColorPalettes": "Пользовательские цветовые палитры",
"DevMode": "Режим разработчика",
"EditTokenWeight": "Редактировать вес токена",
@@ -640,7 +628,6 @@
"Settings": "Настройки",
"Sidebar": "Боковая панель",
"Tree Explorer": "Дерево проводника",
"UV": "UV",
"Validation": "Валидация",
"Window": "Окно",
"Workflow": "Рабочий процесс"

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "Отправлять анонимную статистику использования"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Установить Зеркало",
"tooltip": "Зеркало установки pip по умолчанию"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Зеркало для установки Python",
"tooltip": "Управляемые установки Python загружаются из проекта Astral python-build-standalone. Эта переменная может быть установлена на URL-адрес зеркала для использования другого источника для установок Python. Предоставленный URL заменит https://github.com/astral-sh/python-build-standalone/releases/download в, например, https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Дистрибутивы могут быть прочитаны из локального каталога, используя схему URL file://."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Установочное Зеркало Torch",
"tooltip": "Зеркало для установки pip для pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "Стиль окна",
"options": {
@@ -321,9 +309,6 @@
"name": "Максимум FPS",
"tooltip": "Максимальное количество кадров в секунду, которое холст может рендерить. Ограничивает использование GPU за счёт плавности. Если 0, используется частота обновления экрана. По умолчанию: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Масштабирование комбинированных виджетов меню узлов (списков) при увеличении"
},
"pysssss_SnapToGrid": {
"name": "Всегда привязываться к сетке"
}

View File

@@ -220,7 +220,6 @@
"allowMetricsDescription": "通过发送匿名使用情况指标来帮助改进ComfyUI。不会收集任何个人信息或工作流内容。",
"autoUpdate": "自动更新",
"autoUpdateDescription": "更新可用时自动更新。您将在安装更新之前收到通知。",
"checkingMirrors": "正在检查到Python镜像的网络访问...",
"dataCollectionDialog": {
"collect": {
"errorReports": "错误报告和堆栈跟踪",
@@ -240,12 +239,7 @@
},
"errorUpdatingConsent": "更新同意错误",
"errorUpdatingConsentDetail": "无法更新度量同意设置",
"learnMoreAboutData": "了解更多关于数据收集的信息",
"mirrorSettings": "镜像设置",
"mirrorsReachable": "到Python镜像的网络访问良好",
"mirrorsUnreachable": "对某些python镜像的网络访问不佳",
"pypiMirrorPlaceholder": "输入PyPI镜像URL",
"pythonMirrorPlaceholder": "输入Python镜像URL"
"learnMoreAboutData": "了解更多关于数据收集的信息"
},
"systemLocations": "系统位置",
"unhandledError": "未知错误",
@@ -376,11 +370,6 @@
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
"missingModels": "缺少模型",
"missingModelsMessage": "加载图表时,未找到以下模型"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3D模型",
@@ -609,7 +598,6 @@
"ColorPalette": "色彩主题",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfy桌面版",
"ContextMenu": "上下文菜单",
"CustomColorPalettes": "自定义色彩主题",
"DevMode": "开发模式",
"EditTokenWeight": "编辑令牌权重",
@@ -640,7 +628,6 @@
"Settings": "设置",
"Sidebar": "侧边栏",
"Tree Explorer": "树形浏览器",
"UV": "UV",
"Validation": "验证",
"Window": "窗口",
"Workflow": "工作流"

View File

@@ -5,18 +5,6 @@
"Comfy-Desktop_SendStatistics": {
"name": "发送匿名使用情况统计"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi 安装镜像",
"tooltip": "默认 pip 安装镜像"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python安装镜像",
"tooltip": "管理的Python安装包从Astral python-build-standalone项目下载。此变量可以设置为镜像URL以使用不同的Python安装源。提供的URL将替换https://github.com/astral-sh/python-build-standalone/releases/download例如在https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz中。可以通过使用file:// URL方案从本地目录读取分发包。"
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch安装镜像",
"tooltip": "用于pytorch的Pip安装镜像"
},
"Comfy-Desktop_WindowStyle": {
"name": "窗口样式",
"options": {
@@ -321,9 +309,6 @@
"name": "最大FPS",
"tooltip": "画布允许渲染的最大帧数。限制GPU使用以换取流畅度。如果为0则使用屏幕刷新率。默认值0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "放大时缩放节点组合部件菜单(列表)"
},
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}

View File

@@ -877,15 +877,6 @@ export class ComfyApi extends EventTarget {
async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data
}
/**
* Gets the custom nodes i18n data from the server.
*
* @returns The custom nodes i18n data
*/
async getCustomNodesI18n(): Promise<Record<string, any>> {
return (await axios.get(this.apiURL('/i18n'))).data
}
}
export const api = new ComfyApi()

View File

@@ -422,6 +422,7 @@ export class ComfyApp {
this.dragOverNode = null
// Node handles file drop, we dont use the built in onDropFile handler as its buggy
// If you drag multiple files it will call it multiple times with the same file
// @ts-expect-error This is not a standard event. TODO fix it.
if (n && n.onDragDrop && (await n.onDragDrop(event))) {
return
}
@@ -461,6 +462,7 @@ export class ComfyApp {
this.canvas.adjustMouseEvent(e)
const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY)
if (node) {
// @ts-expect-error This is not a standard event. TODO fix it.
if (node.onDragOver && node.onDragOver(e)) {
this.dragOverNode = node
@@ -571,8 +573,7 @@ export class ComfyApp {
}
const isTargetInGraph =
e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas'
e.target.classList.contains('graph-canvas-container')
// copy nodes and clear clipboard
if (isTargetInGraph && this.canvas.selected_nodes) {
@@ -1036,6 +1037,33 @@ export class ComfyApp {
await useExtensionService().invokeExtensionsAsync('init')
await this.registerNodes()
// Load previous workflow
let restored = false
try {
const loadWorkflow = async (json) => {
if (json) {
const workflow = JSON.parse(json)
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
await this.loadGraphData(workflow, true, true, workflowName)
return true
}
}
const clientId = api.initialClientId ?? api.clientId
restored =
(clientId &&
(await loadWorkflow(
sessionStorage.getItem(`workflow:${clientId}`)
))) ||
(await loadWorkflow(localStorage.getItem('workflow')))
} catch (err) {
console.error('Error loading previous workflow', err)
}
// We failed to restore a workflow so load the default
if (!restored) {
await this.loadGraphData()
}
this.#addDrawNodeHandler()
this.#addDrawGroupsHandler()
this.#addDropHandler()
@@ -1285,19 +1313,17 @@ export class ComfyApp {
graphData.models &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
const modelStore = useModelStore()
for (const m of graphData.models) {
const modelFolder = await modelStore.getLoadedModelFolder(m.directory)
// @ts-expect-error
if (!modelFolder) m.directory_invalid = true
const modelsAvailable = modelFolder?.models
const modelExists =
modelsAvailable &&
Object.values(modelsAvailable).some(
(model) => model.file_name === m.name
)
if (!modelExists) missingModels.push(m)
const models_available = await useModelStore().getLoadedModelFolder(
m.directory
)
if (models_available === null) {
// @ts-expect-error
m.directory_invalid = true
missingModels.push(m)
} else if (!(m.name in models_available.models)) {
missingModels.push(m)
}
}
}

View File

@@ -707,7 +707,8 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
uploadWidget.serialize = false
// Add handler to check if an image is being dragged over our node
node.onDragOver = function (e: DragEvent) {
// @ts-expect-error
node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!image
@@ -717,7 +718,8 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
}
// On drop upload files
node.onDragDrop = function (e: DragEvent) {
// @ts-expect-error
node.onDragDrop = function (e) {
console.log('onDragDrop called')
let handled = false
for (const file of e.dataTransfer.files) {

View File

@@ -2,7 +2,6 @@ import { LGraphCanvas } from '@comfyorg/litegraph'
import { toRaw } from 'vue'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
@@ -122,18 +121,6 @@ export const useWorkflowService = () => {
await app.loadGraphData(defaultGraph)
}
/**
* Load the tutorial workflow
*/
const loadTutorialWorkflow = async () => {
const tutorialWorkflow = await fetch(
api.fileURL('/templates/default.json')
).then((r) => r.json())
await app.loadGraphData(tutorialWorkflow, false, false, 'tutorial', {
showMissingModelsDialog: true
})
}
/**
* Load a blank workflow
*/
@@ -379,7 +366,6 @@ export const useWorkflowService = () => {
saveWorkflow,
loadDefaultWorkflow,
loadBlankWorkflow,
loadTutorialWorkflow,
reloadCurrentWorkflow,
openWorkflow,
closeWorkflow,

View File

@@ -7,7 +7,7 @@ import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
/** State of a maintenance task, managed by the maintenance task store. */
type MaintenanceTaskState = 'warning' | 'error' | 'OK' | 'skipped'
export type MaintenanceTaskState = 'warning' | 'error' | 'OK' | 'skipped'
// Type not exported by API
type ValidationState = InstallValidation['basePath']

View File

@@ -51,20 +51,6 @@ declare module '@comfyorg/litegraph' {
applyToGraph?(extraLinks?: LLink[]): void
updateLink?(link: LLink): LLink | null
onExecutionStart?(): unknown
/**
* Callback invoked when the node is dragged over from an external source, i.e.
* a file or another HTML element.
* @param e The drag event
* @returns {boolean} True if the drag event should be handled by this node, false otherwise
*/
onDragOver?(e: DragEvent): boolean
/**
* Callback invoked when the node is dropped from an external source, i.e.
* a file or another HTML element.
* @param e The drag event
* @returns {boolean} True if the drag event should be handled by this node, false otherwise
*/
onDragDrop?(e: DragEvent): Promise<boolean> | boolean
index?: number
runningInternalNodeId?: NodeId

View File

@@ -8,7 +8,6 @@ export type SettingInputType =
| 'text'
| 'image'
| 'color'
| 'url'
| 'hidden'
export type SettingCustomRenderer = (

View File

@@ -25,11 +25,6 @@ export interface TreeExplorerNode<T = any> {
) => void | Promise<void>
/** Whether the node is draggable */
draggable?: boolean
/** Function to render a drag preview */
renderDragPreview?: (
node: TreeExplorerNode<T>,
container: HTMLElement
) => void | (() => void)
/** Whether the node is droppable */
droppable?: boolean
/** Function to handle dropping a node */

View File

@@ -14,7 +14,3 @@ export function electronAPI() {
export function showNativeMenu(event: MouseEvent) {
electronAPI()?.showContextMenu(event as ElectronContextMenuOptions)
}
export function isNativeWindow() {
return isElectron() && !!window.navigator.windowControlsOverlay?.visible
}

View File

@@ -204,12 +204,3 @@ export function processDynamicPrompt(input: string): string {
return result.replace(/\\([{}|])/g, '$1')
}
export function isValidUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}

View File

@@ -1,26 +0,0 @@
import axios from 'axios'
import { electronAPI } from './envUtil'
import { isValidUrl } from './formatUtil'
const VALID_STATUS_CODES = [200, 201, 301, 302, 307, 308]
export const checkUrlReachable = async (url: string): Promise<boolean> => {
try {
const response = await axios.head(url)
// Additional check for successful response
return VALID_STATUS_CODES.includes(response.status)
} catch {
return false
}
}
/**
* Check if a mirror is reachable from the electron App.
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)
}

View File

@@ -1,19 +0,0 @@
export enum ValidationState {
IDLE = 'IDLE',
LOADING = 'LOADING',
VALID = 'VALID',
INVALID = 'INVALID'
}
export const mergeValidationStates = (states: ValidationState[]) => {
if (states.some((state) => state === ValidationState.INVALID)) {
return ValidationState.INVALID
}
if (states.some((state) => state === ValidationState.LOADING)) {
return ValidationState.LOADING
}
if (states.every((state) => state === ValidationState.VALID)) {
return ValidationState.VALID
}
return ValidationState.IDLE
}

View File

@@ -4,7 +4,7 @@
<TopMenubar />
<GraphCanvas @ready="onGraphReady" />
<GlobalToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<UnloadWindowConfirmDialog />
<BrowserTabTitle />
<MenuHamburger />
</template>

View File

@@ -81,14 +81,7 @@
v-model:autoUpdate="autoUpdate"
v-model:allowMetrics="allowMetrics"
/>
<MirrorsConfiguration
:device="device"
v-model:pythonMirror="pythonMirror"
v-model:pypiMirror="pypiMirror"
v-model:torchMirror="torchMirror"
class="mt-6"
/>
<div class="flex mt-6 justify-between">
<div class="flex pt-6 justify-between">
<Button
:label="$t('g.back')"
severity="secondary"
@@ -127,7 +120,6 @@ import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsCo
import GpuPicker from '@/components/install/GpuPicker.vue'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorsConfiguration from '@/components/install/MirrorsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
@@ -141,9 +133,6 @@ const migrationItemIds = ref<string[]>([])
const autoUpdate = ref(true)
const allowMetrics = ref(true)
const pythonMirror = ref('')
const pypiMirror = ref('')
const torchMirror = ref('')
/** Forces each install step to be visited at least once. */
const highestStep = ref(0)
@@ -173,9 +162,6 @@ const install = () => {
allowMetrics: allowMetrics.value,
migrationSourcePath: migrationSourcePath.value,
migrationItemIds: toRaw(migrationItemIds.value),
pythonMirror: pythonMirror.value,
pypiMirror: pypiMirror.value,
torchMirror: torchMirror.value,
device: device.value
}
electron.installComfyUI(options)

View File

@@ -9,7 +9,7 @@
>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow()"
v-show="isNativeWindow"
ref="topMenuRef"
class="app-drag w-full h-[var(--comfy-topbar-height)]"
/>
@@ -24,7 +24,7 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
import { electronAPI, isElectron } from '@/utils/envUtil'
const props = withDefaults(
defineProps<{
@@ -46,8 +46,12 @@ const lightTheme = {
}
const topMenuRef = ref<HTMLDivElement | null>(null)
const isNativeWindow = ref(false)
onMounted(async () => {
if (isElectron()) {
const windowStyle = await electronAPI().Config.getWindowStyle()
isNativeWindow.value = windowStyle === 'custom'
await nextTick()
electronAPI().changeTheme({

View File

@@ -68,13 +68,6 @@ const mockElectronAPI: Plugin = {
console.log('incrementUserProperty', property, value)
}
},
NetWork: {
canAccessUrl: (url) => {
const canAccess = url.includes('good')
console.log('canAccessUrl', url, canAccess)
return new Promise((resolve) => setTimeout(() => resolve(canAccess), 10000))
}
},
setMetricsConsent: (consent) => {}
};`
}