Compare commits

...

9 Commits

Author SHA1 Message Date
Christian Byrne
90f6c7a6b8 1.20.7 (#4005)
Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-27 22:30:16 -07:00
Robin Huang
0913e9891b [fix] Disable serialization for text preview widget (#4004) 2025-05-27 22:23:08 -07:00
filtered
08a046907d [Test] Disable flaky test (#3994) 2025-05-27 22:23:03 -07:00
filtered
3865e5493a 1.20.6 (#3986) 2025-05-27 14:42:37 +10:00
Christian Byrne
7ea8c340a7 Trigger browser test expectations update (#3959)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-26 21:22:25 -07:00
Terry Jia
5ecb82568e [3d] improve mtl support logic (#3965) 2025-05-26 20:28:53 -07:00
Christian Byrne
1d9d5e5061 v1.20.5 (#3973) 2025-05-23 19:02:51 -07:00
Terry Jia
be1d6d776a [3d] fix wrong generated language translation for 3d node output (#3967)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-23 18:25:18 -07:00
Terry Jia
ada7dde51c [3d] performance improve (#3961) 2025-05-23 18:25:18 -07:00
23 changed files with 786 additions and 246 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -32,7 +32,9 @@ test.describe('Templates', () => {
}
})
test('should have all required thumbnail media for each template', async ({
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 239 KiB

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.4",
"version": "1.20.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.4",
"version": "1.20.6",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.20.4",
"version": "1.20.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -0,0 +1,293 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,15 +7,12 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -37,8 +34,11 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,6 +77,7 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -87,6 +88,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -94,14 +96,34 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (email: string) => {
if (!email) return
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -6,7 +6,7 @@
<script setup lang="ts">
import { LGraphNode } from '@comfyorg/litegraph'
import { onMounted, onUnmounted, ref, toRaw, watch, watchEffect } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -76,18 +76,71 @@ const eventConfig = {
emit('recordingStatusChange', value)
} as const
watchEffect(() => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
watch(
() => props.showPreview,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(props.backgroundColor)
rawLoad3d.toggleGrid(props.showGrid)
rawLoad3d.setLightIntensity(props.lightIntensity)
rawLoad3d.setFOV(props.fov)
rawLoad3d.toggleCamera(props.cameraType)
rawLoad3d.togglePreview(props.showPreview)
rawLoad3d.togglePreview(newValue)
}
}
})
)
watch(
() => props.cameraType,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleCamera(newValue)
}
}
)
watch(
() => props.fov,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setFOV(newValue)
}
}
)
watch(
() => props.lightIntensity,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setLightIntensity(newValue)
}
}
)
watch(
() => props.showGrid,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleGrid(newValue)
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(newValue)
}
}
)
watch(
() => props.backgroundImage,
@@ -164,12 +217,13 @@ const handleEvents = (action: 'add' | 'remove') => {
}
onMounted(() => {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.inputSpec
)
if (container.value) {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
container.value,
props.inputSpec
)
}
handleEvents('add')
})

View File

@@ -28,7 +28,8 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})
addWidget(node, widget)

View File

@@ -112,7 +112,7 @@ useExtensionService().registerExtension({
LOAD_3D(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.obj,.mtl,.fbx,.stl'
fileInput.accept = '.gltf,.glb,.obj,.fbx,.stl'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
@@ -195,9 +195,7 @@ useExtensionService().registerExtension({
await nextTick()
const load3d = useLoad3dService().getLoad3d(node)
if (load3d) {
useLoad3dService().waitForLoad3d(node, (load3d) => {
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
@@ -256,7 +254,7 @@ useExtensionService().registerExtension({
return returnVal
}
}
}
})
}
})
@@ -268,7 +266,7 @@ useExtensionService().registerExtension({
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.fbx,glb,gltf'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
if (fileInput.files?.length) {
@@ -346,67 +344,65 @@ useExtensionService().registerExtension({
await nextTick()
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
useLoad3dService().waitForLoad3d(node, (load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let cameraState = node.properties['Camera Info']
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3d = useLoad3dService().getLoad3d(node) as Load3dAnimation
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
config.configure('input', modelWidget, cameraState, width, height)
let cameraState = node.properties['Camera Info']
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3dAnimation = load3d as Load3dAnimation
load3dAnimation.toggleAnimation(false)
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
if (load3dAnimation.isRecording()) {
load3dAnimation.stopRecording()
}
config.configure('input', modelWidget, cameraState, width, height)
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3dAnimation.captureScene(
width.value as number,
height.value as number
)
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
load3d.toggleAnimation(false)
if (load3d.isRecording()) {
load3d.stopRecording()
}
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3d.captureScene(
width.value as number,
height.value as number
)
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
load3d.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3d.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
load3dAnimation.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3dAnimation.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
}
}
}
})
}
})
@@ -456,31 +452,43 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
const load3d = useLoad3dService().getLoad3d(node)
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
const config = new Load3DConfiguration(load3d)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
config.configure('output', modelWidget, cameraState)
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})
@@ -530,29 +538,42 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
const load3d = useLoad3dService().getLoad3d(node)
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
const config = new Load3DConfiguration(load3d)
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
config.configure('output', modelWidget, cameraState)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})

View File

@@ -486,6 +486,14 @@ class Load3d {
}
public remove(): void {
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}

View File

@@ -132,6 +132,14 @@ export class LoaderManager implements LoaderManagerInterface {
if (this.modelManager.materialMode === 'original') {
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
const subfolder = subfolderMatch
? decodeURIComponent(subfolderMatch[1])
: '3d'
this.mtlLoader.setSubfolder(subfolder)
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()

View File

@@ -62,6 +62,14 @@ export class PreviewManager implements PreviewManagerInterface {
dispose(): void {
if (this.previewRenderer) {
this.previewRenderer.forceContextLoss()
const canvas = this.previewRenderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.previewRenderer.dispose()
}

View File

@@ -38,6 +38,10 @@ class OverrideMTLLoader extends Loader {
this.loadRootFolder = loadRootFolder
}
setSubfolder(subfolder) {
this.subfolder = subfolder
}
/**
* Starts loading from the given URL and passes the loaded MTL asset
* to the `onLoad()` callback.
@@ -135,7 +139,8 @@ class OverrideMTLLoader extends Loader {
const materialCreator = new OverrideMaterialCreator(
this.resourcePath || path,
this.materialOptions,
this.loadRootFolder
this.loadRootFolder,
this.subfolder
)
materialCreator.setCrossOrigin(this.crossOrigin)
materialCreator.setManager(this.manager)
@@ -155,7 +160,7 @@ class OverrideMTLLoader extends Loader {
*/
class OverrideMaterialCreator {
constructor(baseUrl = '', options = {}, loadRootFolder) {
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
this.baseUrl = baseUrl
this.options = options
this.materialsInfo = {}
@@ -164,6 +169,7 @@ class OverrideMaterialCreator {
this.nameLookup = {}
this.loadRootFolder = loadRootFolder
this.subfolder = subfolder
this.crossOrigin = 'anonymous'
@@ -283,16 +289,25 @@ class OverrideMaterialCreator {
/**
* Override for ComfyUI api url
*/
function resolveURL(baseUrl, url, loadRootFolder) {
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
if (typeof url !== 'string' || url === '') return ''
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1)
}
if (!baseUrl.endsWith('api')) {
baseUrl = '/api'
}
baseUrl =
baseUrl +
'/view?filename=' +
url +
'&type=' +
loadRootFolder +
'&subfolder=3d'
'&subfolder=' +
subfolder
return baseUrl
}
@@ -302,7 +317,12 @@ class OverrideMaterialCreator {
const texParams = scope.getTextureParams(value, params)
const map = scope.loadTexture(
resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder)
resolveURL(
scope.baseUrl,
texParams.url,
scope.loadRootFolder,
scope.subfolder
)
)
map.repeat.copy(texParams.scale)

View File

@@ -3403,7 +3403,7 @@
"clear": {
},
"height": {
"name": "altura"
"name": "alto"
},
"image": {
"name": "imagen"
@@ -3417,20 +3417,26 @@
"name": "ancho"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"name": "normal"
},
{
"4": {
"name": "lineart"
},
{
"name": "camera_info"
"5": {
"name": "info_cámara"
}
]
}
},
"Load3DAnimation": {
"display_name": "Cargar 3D - Animación",
@@ -3438,7 +3444,7 @@
"clear": {
},
"height": {
"name": "altura"
"name": "alto"
},
"image": {
"name": "imagen"
@@ -3452,17 +3458,23 @@
"name": "ancho"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"name": "normal"
},
{
"name": "camera_info"
"4": {
"name": "info_cámara"
}
]
}
},
"LoadAudio": {
"display_name": "CargarAudio",

View File

@@ -3417,20 +3417,26 @@
"name": "largeur"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
{
"name": "ligne artistique"
"4": {
"name": "lineart"
},
{
"name": "informations caméra"
"5": {
"name": "info_caméra"
}
]
}
},
"Load3DAnimation": {
"display_name": "Charger 3D - Animation",
@@ -3452,17 +3458,23 @@
"name": "largeur"
}
},
"outputs": [
null,
null,
null,
{
"name": "normal"
"outputs": {
"0": {
"name": "image"
},
{
"name": "camera_info"
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
"4": {
"name": "info_caméra"
}
]
}
},
"LoadAudio": {
"display_name": "ChargerAudio",

View File

@@ -3417,23 +3417,29 @@
"name": "幅"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"name": "法線"
},
{
"4": {
"name": "線画"
},
{
"5": {
"name": "カメラ情報"
}
]
}
},
"Load3DAnimation": {
"display_name": "3D読み込 - アニメーション",
"display_name": "3D読み込 - アニメーション",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "幅"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"name": "法線"
},
{
"4": {
"name": "カメラ情報"
}
]
}
},
"LoadAudio": {
"display_name": "音声を読み込む",

View File

@@ -3398,7 +3398,7 @@
}
},
"Load3D": {
"display_name": "3D 로드",
"display_name": "3D 불러오기",
"inputs": {
"clear": {
},
@@ -3417,23 +3417,29 @@
"name": "너비"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"name": "노멀"
},
{
"4": {
"name": "라인아트"
},
{
"5": {
"name": "카메라 정보"
}
]
}
},
"Load3DAnimation": {
"display_name": "3D 로드 - 애니메이션",
"display_name": "3D 불러오기 - 애니메이션",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "너비"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"name": "노멀"
},
{
"4": {
"name": "카메라 정보"
}
]
}
},
"LoadAudio": {
"display_name": "오디오 로드",

View File

@@ -3409,7 +3409,7 @@
"name": "изображение"
},
"model_file": {
"name": "файл_модели"
"name": "файл модели"
},
"upload 3d model": {
},
@@ -3417,23 +3417,29 @@
"name": "ширина"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь к mesh"
},
"3": {
"name": "нормаль"
},
{
"name": "линеарт"
"4": {
"name": "линейный рисунок"
},
{
"name": "информация_камеры"
"5": {
"name": "информация о камере"
}
]
}
},
"Load3DAnimation": {
"display_name": "Загрузить 3D Анимация",
"display_name": "Загрузить 3D - Анимация",
"inputs": {
"clear": {
},
@@ -3452,17 +3458,23 @@
"name": "ширина"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь_к_модели"
},
"3": {
"name": "нормаль"
},
{
"name": "информация_камеры"
"4": {
"name": "информация_о_камере"
}
]
}
},
"LoadAudio": {
"display_name": "Загрузить аудио",

View File

@@ -3417,20 +3417,26 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"name": "法线"
"outputs": {
"0": {
"name": "image"
},
{
"name": "线稿"
"1": {
"name": "mask"
},
{
"name": "相机信息"
"2": {
"name": "mesh_path"
},
"3": {
"name": "normal"
},
"4": {
"name": "lineart"
},
"5": {
"name": "camera_info"
}
]
}
},
"Load3DAnimation": {
"display_name": "加载3D动画",
@@ -3452,17 +3458,23 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "mesh_path"
},
"3": {
"name": "法线"
},
{
"4": {
"name": "相机信息"
}
]
}
},
"LoadAudio": {
"display_name": "加载音频",

View File

@@ -1074,11 +1074,11 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
) {
if (graphData.extra?.ds) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {

View File

@@ -5,9 +5,12 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
private pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
private constructor() {}
@@ -60,6 +63,13 @@ export class Load3dService {
this.nodeToLoad3dMap.set(rawNode, instance)
const callbacks = this.pendingCallbacks.get(rawNode)
if (callbacks) {
callbacks.forEach((callback) => callback(instance))
this.pendingCallbacks.delete(rawNode)
}
return instance
}
@@ -69,6 +79,24 @@ export class Load3dService {
return this.nodeToLoad3dMap.get(rawNode) || null
}
waitForLoad3d(node: LGraphNode, callback: Load3dReadyCallback): void {
const rawNode = toRaw(node)
const existingInstance = this.nodeToLoad3dMap.get(rawNode)
if (existingInstance) {
callback(existingInstance)
return
}
if (!this.pendingCallbacks.has(rawNode)) {
this.pendingCallbacks.set(rawNode, [])
}
this.pendingCallbacks.get(rawNode)!.push(callback)
}
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
for (const [node, instance] of this.nodeToLoad3dMap) {
if (instance === load3d) {
@@ -88,12 +116,15 @@ export class Load3dService {
this.nodeToLoad3dMap.delete(rawNode)
}
this.pendingCallbacks.delete(rawNode)
}
clear() {
for (const [node] of this.nodeToLoad3dMap) {
this.removeLoad3d(node)
}
this.pendingCallbacks.clear()
}
}