Compare commits

...

40 Commits

Author SHA1 Message Date
filtered
99009a18f7 1.8.8 (#2363) 2025-01-27 22:58:11 +11:00
filtered
e3ab0e4d68 Add option to disable combo box zoom-in scaling (#2362)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-27 22:56:40 +11:00
filtered
0e1ae41c0c Update litegraph 0.8.62 (#2360) 2025-01-27 22:32:24 +11:00
filtered
f12d4a2d6f [Test] Update test expectations for 2357 (#2361) 2025-01-27 22:09:25 +11:00
bymyself
7bd8527bca Add cursor drop effect when dragging nodes/models onto graph (#2354) 2025-01-26 07:08:10 -05:00
bymyself
cb356d50b8 Set Node ID badges to hidden by default (#2356) 2025-01-26 07:07:05 -05:00
filtered
d2e9943e79 Add convert to input for the clicked widget (#2357)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-26 07:06:41 -05:00
Chenlei Hu
27e4bd2592 [Refactor] Use computed states for maintenance/StatusTag (#2352) 2025-01-25 20:56:05 -05:00
Chenlei Hu
82e0c3a8b6 1.8.7 (#2351) 2025-01-25 20:12:21 -05:00
Chenlei Hu
2852720b2c [Refactor] Extract workflow/workflow tabs restore into a composable (#2350) 2025-01-25 16:58:50 -05:00
Chenlei Hu
38b8a68e50 [Refactor] Move workflow restore from app to GraphCanvas (#2349) 2025-01-25 15:36:50 -05:00
Chenlei Hu
44321e4692 [DevExperience] Add default VSCode launch.json (#2348) 2025-01-25 12:53:36 -05:00
bymyself
e992bd6571 Add tutorial workflow function for new desktop users (#2315)
Co-authored-by: huchenlei <huchenlei@proton.me>
2025-01-25 12:23:27 -05:00
bymyself
e971ba31e0 Add "Don't show this again" checkbox to missing models dialog (#2344)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-25 11:42:01 -05:00
Terry Jia
28b163cdd5 [3d] animation node UI change (#2347) 2025-01-25 11:41:05 -05:00
bymyself
652125de1f Use v1-5-pruned-emaonly-fp16 model in default template workflow (#2346) 2025-01-25 11:40:39 -05:00
bymyself
c5d153cf16 Enable missing models dialog by default (#2345) 2025-01-25 11:40:18 -05:00
Chenlei Hu
9459f599b6 1.8.6 (#2343) 2025-01-24 22:41:38 -05:00
Terry Jia
326839db88 [3d] add preview 3d animation node (#2341) 2025-01-24 22:35:28 -05:00
Chenlei Hu
30fdc70218 Click url input status icon to trigger validation (#2339) 2025-01-24 14:15:54 -05:00
bymyself
9c42c31968 Fix missing models dialog (#2336) 2025-01-24 10:37:19 -05:00
Chenlei Hu
44aa1bf8c3 1.8.5 (#2335) 2025-01-23 22:06:57 -05:00
Chenlei Hu
caad27e28d Validate on mount for UrlInput (#2332) 2025-01-23 15:09:00 -05:00
Chenlei Hu
e8136ff0ae UrlInput emits update:modelValue only on blur (#2331) 2025-01-23 14:55:12 -05:00
Chenlei Hu
157475cb2e Add validateUrlFn props on UrlInput component (#2330) 2025-01-23 14:44:06 -05:00
Chenlei Hu
93dc50a95a Allow passthrough attrs on UrlInput (#2329) 2025-01-23 14:29:38 -05:00
Terry Jia
3f787e2dbf [3d] improve storing Camera State logic (#2328) 2025-01-23 14:26:13 -05:00
Terry Jia
b54e270b10 [3d] refactor code (#2326) 2025-01-23 11:26:27 -05:00
Chenlei Hu
0ab1d974c0 Add url setting type (#2327) 2025-01-23 11:25:06 -05:00
bymyself
95ff01a67b Fix form validation message appearing unnecessarily (#2324) 2025-01-23 09:25:36 -05:00
filtered
6f05ce6cc2 [Desktop] Add restart app instruction to git fix task (#2322) 2025-01-23 09:25:04 -05:00
bymyself
b8bef57522 Clean tags in issue reports (#2323) 2025-01-23 09:23:07 -05:00
bymyself
46500bf3dd Fix translation id in folder customization dialog (#2320) 2025-01-22 20:44:02 -05:00
Chenlei Hu
1bcc00cd33 [i18n] Load custom nodes locales from ComfyUI server (#2314) 2025-01-22 17:37:02 -05:00
Chenlei Hu
08d2322817 1.8.4 (#2319) 2025-01-22 15:37:07 -05:00
filtered
8cfc1c4682 [Desktop] Remove beforeunload handler - unusable in electron (#2318) 2025-01-22 15:35:21 -05:00
filtered
c7bce87b8d [Desktop] Use Window Controls Overlay API (#2316)
Co-authored-by: huchenlei <huchenlei@proton.me>
2025-01-22 13:22:21 -05:00
Chenlei Hu
8f6b594a9f Fix console error on adding node via searchbox (#2317) 2025-01-22 13:13:26 -05:00
Chenlei Hu
1c9b300396 1.8.3 (#2312) 2025-01-21 20:36:54 -05:00
filtered
cd5283c4b7 [Refactor] Desktop maintenance task runner (#2311) 2025-01-21 20:18:01 -05:00
61 changed files with 2605 additions and 1668 deletions

1
.gitignore vendored
View File

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

16
.vscode/launch.json.default vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// 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 { expect } from '@playwright/test'
import { Locator, expect } from '@playwright/test'
import { Keybinding } from '../src/types/keyBindingTypes'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
@@ -66,9 +66,71 @@ 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 display a warning when missing models are found', async ({
test.skip('Should download missing model when clicking download button', async ({
comfyPage
}) => {
// The fake_model.safetensors is served by
@@ -86,6 +148,49 @@ 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,7 +904,9 @@ 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
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true
})
} catch (e) {
console.error(e)

View File

@@ -102,6 +102,18 @@ 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: 109 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

12
global.d.ts vendored
View File

@@ -1,3 +1,15 @@
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
}
}

12
package-lock.json generated
View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.8.2",
"version": "1.8.8",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -84,7 +84,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.11",
"@comfyorg/litegraph": "^0.8.61",
"@comfyorg/litegraph": "^0.8.62",
"@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.safetensors"
"v1-5-pruned-emaonly-fp16.safetensors"
]
}
],
@@ -349,8 +349,8 @@
"extra": {},
"version": 0.4,
"models": [{
"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",
"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",
"directory": "checkpoints"
}]
}

View File

@@ -765,6 +765,30 @@ 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('g.customColor')"
v-tooltip="$t('color.custom')"
></i>
</template>
</SelectButton>

View File

@@ -35,6 +35,7 @@ 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')
@@ -91,6 +92,8 @@ function getFormComponent(item: FormItem): Component {
return FormImageUpload
case 'color':
return FormColorPicker
case 'url':
return UrlInput
default:
return InputText
}

View File

@@ -0,0 +1,116 @@
<template>
<IconField class="w-full">
<InputText
v-bind="$attrs"
:model-value="internalValue"
class="w-full"
:invalid="validationState === UrlValidationState.INVALID"
@update:model-value="handleInput"
@blur="handleBlur"
/>
<InputIcon
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === UrlValidationState.LOADING,
'pi pi-check text-green-500 cursor-pointer':
validationState === UrlValidationState.VALID,
'pi pi-times text-red-500 cursor-pointer':
validationState === UrlValidationState.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'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
enum UrlValidationState {
IDLE = 'IDLE',
LOADING = 'LOADING',
VALID = 'VALID',
INVALID = 'INVALID'
}
const validationState = ref<UrlValidationState>(UrlValidationState.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)
}
)
// 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 = UrlValidationState.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 === UrlValidationState.LOADING) return
const url = value.trim()
// Reset state
validationState.value = UrlValidationState.IDLE
// Skip validation if empty
if (!url) return
validationState.value = UrlValidationState.LOADING
try {
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? UrlValidationState.VALID
: UrlValidationState.INVALID
} catch {
validationState.value = UrlValidationState.INVALID
}
}
// Add inheritAttrs option to prevent attrs from being applied to root element
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -0,0 +1,158 @@
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,9 +2,15 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Models"
message="When loading the graph, the following models were not found"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<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()">
@@ -25,11 +31,14 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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
@@ -58,6 +67,10 @@ 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) => {
@@ -107,6 +120,12 @@ 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"
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
@@ -105,6 +105,7 @@ 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'
@@ -218,7 +219,7 @@ const createCaptureContext = async (
? formData.notifyOnResolution
: false,
isElectron: isElectron(),
...props.tags
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
},
extra: {
details: formData.details,

View File

@@ -54,10 +54,11 @@ import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { useWorkflowPersistence } from '@/hooks/workflowPersistenceHooks'
import { i18n } from '@/i18n'
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'
@@ -71,7 +72,6 @@ import {
} 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'
@@ -95,29 +95,6 @@ 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) {
@@ -269,28 +246,15 @@ watch(
}
)
const workflowStore = useWorkflowStore()
const persistCurrentWorkflow = () => {
const workflow = JSON.stringify(comfyApp.serializeGraph())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
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()
}
LiteGraph.context_menu_scaling = settingStore.get(
'LiteGraph.ContextMenu.Scaling'
)
})
api.addEventListener('graphChanged', persistCurrentWorkflow)
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
@@ -348,7 +312,20 @@ usePragmaticDroppable(() => canvasRef.value, {
}
})
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 comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
onMounted(async () => {
// Backward compatible
// Assign all properties of lg to window
@@ -368,6 +345,7 @@ onMounted(async () => {
// 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)
@@ -387,17 +365,9 @@ onMounted(async () => {
'Comfy.CustomColorPalettes'
)
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))
})
// Restore workflow and workflow tabs state from storage
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
// Start watching for locale change after the initial value is loaded.
watch(

View File

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

View File

@@ -5,12 +5,12 @@
>
<Card
class="max-w-48 relative h-full overflow-hidden"
:class="{ 'opacity-65': state.state !== 'error' }"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="state.state === 'error'"
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
style="font-size: 10rem"
/>
@@ -38,7 +38,7 @@
</Card>
<i
v-if="!isLoading && state.state === 'OK'"
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
/>
</div>
@@ -54,7 +54,7 @@ import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
const state = computed(() => taskStore.getState(props.task))
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
@@ -68,14 +68,14 @@ defineEmits<{
// Bindings
const description = computed(() =>
state.value.state === 'error'
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
: props.task.shortDescription
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => state.value.refreshing)
const reactiveExecuting = computed(() => state.value.executing)
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)

View File

@@ -2,12 +2,12 @@
<tr
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': state.state === 'resolved',
'opacity-75': isLoading && state.state !== 'resolved'
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<TaskListStatusIcon :state="state.state" :loading="isLoading" />
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
<p class="inline-block">{{ task.name }}</p>
@@ -51,7 +51,7 @@ import { useMinLoadingDurationRef } from '@/utils/refUtil'
import TaskListStatusIcon from './TaskListStatusIcon.vue'
const taskStore = useMaintenanceTaskStore()
const state = computed(() => taskStore.getState(props.task))
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
@@ -65,14 +65,14 @@ defineEmits<{
// Binding
const severity = computed<VueSeverity>(() =>
state.value.state === 'error' || state.value.state === 'warning'
runner.value.state === 'error' || runner.value.state === 'warning'
? 'primary'
: 'secondary'
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => state.value.refreshing)
const reactiveExecuting = computed(() => state.value.executing)
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)

View File

@@ -60,8 +60,9 @@
<!-- 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)"
@remove="onRemoveFilter($event, value as FilterAndValue)"
:text="value[1]"
:badge="value[0].invokeSequence.toUpperCase()"
:badge-class="value[0].invokeSequence + '-badge'"

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,7 +50,12 @@ 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, showNativeMenu } from '@/utils/envUtil'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeMenu
} from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -64,10 +69,6 @@ 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: false,
defaultValue: true,
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.ShowAll
defaultValue: NodeBadgeMode.None
},
{
id: 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
@@ -708,5 +708,19 @@ 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,8 +31,10 @@ 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 fixer simply opens the download page in your browser. You must download and install git manually.',
'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.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
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,
bgColor: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
fov: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
this.setupModelHandling(
modelWidget,
loadFolder,
cameraState,
postModelUpdateFunc
)
this.setupMaterial(material)
this.setupBackground(bgColor)
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 setupBackground(bgColor: IWidget) {
bgColor.callback = (value: string) => {
this.load3d.setBackgroundColor(value)
}
this.load3d.setBackgroundColor(bgColor.value as string)
}
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)
}
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

@@ -0,0 +1,945 @@
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
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.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 || !(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)
}
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)
}
}
export default Load3d

View File

@@ -0,0 +1,304 @@
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
constructor(container: Element | HTMLElement) {
super(container)
this.createPlayPauseButton(container)
this.createAnimationList(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'
}
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.updateAnimationList()
} else {
this.playPauseContainer.style.display = 'none'
this.animationSelect.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 = ''
}
}
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

@@ -0,0 +1,122 @@
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,7 +782,27 @@ 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

@@ -0,0 +1,130 @@
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

@@ -471,7 +471,8 @@
"Server-Config": "Server-Config",
"About": "About",
"EditTokenWeight": "Edit Token Weight",
"CustomColorPalettes": "Custom Color Palettes"
"CustomColorPalettes": "Custom Color Palettes",
"ContextMenu": "Context Menu"
},
"serverConfigItems": {
"listen": {
@@ -708,5 +709,10 @@
"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

@@ -309,6 +309,9 @@
"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

@@ -370,6 +370,11 @@
"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",
@@ -598,6 +603,7 @@
"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",

View File

@@ -309,6 +309,9 @@
"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

@@ -370,6 +370,11 @@
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
"missingModels": "モデルが見つかりません",
"missingModelsMessage": "グラフを読み込む際に、次のモデルが見つかりませんでした"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3Dモデル",
@@ -598,6 +603,7 @@
"ColorPalette": "カラーパレット",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfyデスクトップ",
"ContextMenu": "コンテキストメニュー",
"CustomColorPalettes": "カスタムカラーパレット",
"DevMode": "開発モード",
"EditTokenWeight": "トークンの重みを編集",

View File

@@ -309,6 +309,9 @@
"name": "最大FPS",
"tooltip": "キャンバスがレンダリングできる最大フレーム数です。スムーズさの代わりにGPU使用量を制限します。0の場合、画面のリフレッシュレートが使用されます。デフォルト0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "ズームイン時にノードコンボウィジェットメニュー(リスト)をスケーリングする"
},
"pysssss_SnapToGrid": {
"name": "常にグリッドにスナップ"
}

View File

@@ -370,6 +370,11 @@
"Zoom In": "확대",
"Zoom Out": "축소"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
"missingModels": "모델이 없습니다",
"missingModelsMessage": "그래프를 로드할 때 다음 모델을 찾을 수 없었습니다"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3D 모델",
@@ -598,6 +603,7 @@
"ColorPalette": "색상 팔레트",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfy-Desktop",
"ContextMenu": "컨텍스트 메뉴",
"CustomColorPalettes": "사용자 정의 색상 팔레트",
"DevMode": "개발자 모드",
"EditTokenWeight": "토큰 가중치 편집",

View File

@@ -309,6 +309,9 @@
"name": "최대 FPS",
"tooltip": "캔버스가 렌더링할 수 있는 최대 프레임 수입니다. 부드럽게 동작하도록 GPU 사용률을 제한 합니다. 0이면 화면 주사율로 작동 합니다. 기본값: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "확대시 노드 콤보 위젯 메뉴 (목록) 스케일링"
},
"pysssss_SnapToGrid": {
"name": "항상 그리드에 스냅"
}

View File

@@ -370,6 +370,11 @@
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
"missingModels": "Отсутствующие модели",
"missingModelsMessage": "При загрузке графа следующие модели не были найдены"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3d_модели",
@@ -598,6 +603,7 @@
"ColorPalette": "Цветовая палитра",
"Comfy": "Comfy",
"Comfy-Desktop": "Десктопный Comfy",
"ContextMenu": "Контекстное меню",
"CustomColorPalettes": "Пользовательские цветовые палитры",
"DevMode": "Режим разработчика",
"EditTokenWeight": "Редактировать вес токена",

View File

@@ -309,6 +309,9 @@
"name": "Максимум FPS",
"tooltip": "Максимальное количество кадров в секунду, которое холст может рендерить. Ограничивает использование GPU за счёт плавности. Если 0, используется частота обновления экрана. По умолчанию: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Масштабирование комбинированных виджетов меню узлов (списков) при увеличении"
},
"pysssss_SnapToGrid": {
"name": "Всегда привязываться к сетке"
}

View File

@@ -370,6 +370,11 @@
"Zoom In": "放大画面",
"Zoom Out": "缩小画面"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
"missingModels": "缺少模型",
"missingModelsMessage": "加载图表时,未找到以下模型"
},
"nodeCategories": {
"3d": "3d",
"3d_models": "3D模型",
@@ -598,6 +603,7 @@
"ColorPalette": "色彩主题",
"Comfy": "Comfy",
"Comfy-Desktop": "Comfy桌面版",
"ContextMenu": "上下文菜单",
"CustomColorPalettes": "自定义色彩主题",
"DevMode": "开发模式",
"EditTokenWeight": "编辑令牌权重",

View File

@@ -309,6 +309,9 @@
"name": "最大FPS",
"tooltip": "画布允许渲染的最大帧数。限制GPU使用以换取流畅度。如果为0则使用屏幕刷新率。默认值0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "放大时缩放节点组合部件菜单(列表)"
},
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}

View File

@@ -877,6 +877,15 @@ 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

@@ -1037,33 +1037,6 @@ 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()
@@ -1313,17 +1286,19 @@ export class ComfyApp {
graphData.models &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
const modelStore = useModelStore()
for (const m of graphData.models) {
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)
}
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)
}
}

View File

@@ -2,6 +2,7 @@ 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'
@@ -121,6 +122,18 @@ 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
*/
@@ -366,6 +379,7 @@ export const useWorkflowService = () => {
saveWorkflow,
loadDefaultWorkflow,
loadBlankWorkflow,
loadTutorialWorkflow,
reloadCurrentWorkflow,
openWorkflow,
closeWorkflow,

View File

@@ -3,12 +3,77 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
import type {
MaintenanceTask,
MaintenanceTaskState
} from '@/types/desktop/maintenanceTypes'
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'
// Type not exported by API
type ValidationState = InstallValidation['basePath']
// Add index to API type
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState
/** The current state of the task. Setter also controls {@link resolved} as a side-effect. */
get state() {
return this._state
}
/** Updates the task state and {@link resolved} status. */
setState(value: MaintenanceTaskState) {
// Mark resolved
if (this._state === 'error' && value === 'OK') this.resolved = true
// Mark unresolved (if previously resolved)
if (value === 'error') this.resolved &&= false
this._state = value
}
/** `true` if the task has been resolved (was `error`, now `OK`). This is a side-effect of the {@link state} setter. */
resolved?: boolean
/** Whether the task state is currently being refreshed. */
refreshing?: boolean
/** Whether the task is currently running. */
executing?: boolean
/** The error message that occurred when the task failed. */
error?: string
update(update: IndexedUpdate) {
const state = update[this.task.id]
this.refreshing = state === undefined
if (state) this.setState(state)
}
finaliseUpdate(update: IndexedUpdate) {
this.refreshing = false
this.setState(update[this.task.id] ?? 'skipped')
}
/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
async execute(task: MaintenanceTask) {
try {
this.executing = true
const success = await task.execute()
if (!success) return false
this.error = undefined
return true
} catch (error) {
this.error = (error as Error)?.message
throw error
} finally {
this.executing = false
}
}
}
/**
* User-initiated maintenance tasks. Currently only used by the desktop app maintenance view.
*
@@ -24,78 +89,46 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const isRunningTerminalCommand = computed(() =>
tasks.value
.filter((task) => task.usesTerminal)
.some((task) => getState(task)?.executing)
.some((task) => getRunner(task)?.executing)
)
const isRunningInstallationFix = computed(() =>
tasks.value
.filter((task) => task.isInstallationFix)
.some((task) => getState(task)?.executing)
.some((task) => getRunner(task)?.executing)
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
const taskStates = ref(
new Map<MaintenanceTask['id'], MaintenanceTaskState>(
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, {}])
new Map<MaintenanceTask['id'], MaintenanceTaskRunner>(
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, new MaintenanceTaskRunner(x)])
)
)
/** True if any tasks are in an error state. */
const anyErrors = computed(() =>
tasks.value.some((task) => getState(task).state === 'error')
tasks.value.some((task) => getRunner(task).state === 'error')
)
/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
const execute = async (task: MaintenanceTask) => {
const state = getState(task)
try {
state.executing = true
const success = await task.execute()
if (!success) return false
state.error = undefined
return true
} catch (error) {
state.error = (error as Error)?.message
throw error
} finally {
state.executing = false
}
}
/**
* Returns the matching state object for a task.
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
const getState = (task: MaintenanceTask) => taskStates.value.get(task.id)!
const getRunner = (task: MaintenanceTask) => taskStates.value.get(task.id)!
/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
// Type not exported by API
type ValidationState = InstallValidation['basePath']
// Add index to API type
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
// Update each task state
for (const task of tasks.value) {
const state = getState(task)
state.refreshing = update[task.id] === undefined
// Mark resolved
if (state.state === 'error' && update[task.id] === 'OK')
state.state = 'resolved'
if (update[task.id] === 'OK' && state.state === 'resolved') continue
if (update[task.id]) state.state = update[task.id]
getRunner(task).update(update)
}
// Final update
@@ -103,11 +136,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing.value = false
for (const task of tasks.value) {
const state = getState(task)
state.refreshing = false
if (state.state === 'resolved') continue
state.state = update[task.id] ?? 'skipped'
getRunner(task).finaliseUpdate(update)
}
}
}
@@ -115,8 +144,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
/** Clears the resolved status of tasks (when changing filters) */
const clearResolved = () => {
for (const task of tasks.value) {
const state = getState(task)
if (state?.state === 'resolved') state.state = 'OK'
getRunner(task).resolved &&= false
}
}
@@ -127,13 +155,17 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
await electron.Validation.validateInstallation(processUpdate)
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
}
return {
tasks,
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
execute,
getState,
getRunner,
processUpdate,
clearResolved,
/** True if any tasks are in an error state. */

View File

@@ -39,18 +39,6 @@ export interface MaintenanceTask {
isInstallationFix?: boolean
}
/** State of a maintenance task, managed by the maintenance task store. */
export interface MaintenanceTaskState {
/** The current state of the task. */
state?: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped'
/** Whether the task state is currently being refreshed. */
refreshing?: boolean
/** Whether the task is currently running. */
executing?: boolean
/** The error message that occurred when the task failed. */
error?: string
}
/** The filter options for the maintenance task list. */
export interface MaintenanceFilter {
/** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */

View File

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

View File

@@ -14,3 +14,7 @@ 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,3 +204,12 @@ 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
}
}

12
src/utils/networkUtil.ts Normal file
View File

@@ -0,0 +1,12 @@
import axios from 'axios'
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
}
}

View File

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

View File

@@ -125,8 +125,8 @@ const displayAsList = ref(PrimeIcons.TH_LARGE)
const errorFilter = computed(() =>
taskStore.tasks.filter((x) => {
const { state } = taskStore.getState(x)
return state === 'error' || state === 'resolved'
const { state, resolved } = taskStore.getRunner(x)
return state === 'error' || resolved
})
)

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 } from '@/utils/envUtil'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
const props = withDefaults(
defineProps<{
@@ -46,12 +46,8 @@ 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({