Compare commits

..

1 Commits

Author SHA1 Message Date
Glary-Bot
d3af003224 chore: update contact email from hello@comfy.org to support@comfy.org 2026-06-10 17:45:05 +00:00
50 changed files with 367 additions and 1624 deletions

View File

@@ -101,15 +101,15 @@ onMounted(() => {
<div class="min-h-[640px] w-full">
<p
v-if="hasEmbedLoadError"
class="text-primary-comfy-canvas text-sm/6"
class="text-sm/6 text-primary-comfy-canvas"
role="status"
>
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
<a
class="text-primary-comfy-yellow underline"
href="mailto:hello@comfy.org"
href="mailto:support@comfy.org"
>
hello@comfy.org
support@comfy.org
</a>
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
</p>

View File

@@ -8,7 +8,6 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -70,7 +69,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -53,28 +53,3 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,12 +38,3 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
@@ -18,87 +17,245 @@ test.describe('Background Image Upload', () => {
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
})
const openBackgroundImageSetting = async (comfyPage: ComfyPage) => {
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.locator('text=Appearance').click()
return comfyPage.page.locator('#Comfy\\.Canvas\\.BackgroundImage')
}
test('should show background image upload component in settings', async ({
comfyPage
}) => {
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
await expect(backgroundImageSetting).toBeVisible()
// With no image set: placeholder shown, no remove button
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Clicking the row opens the system file browser
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await backgroundImageSetting
.getByRole('button', { name: 'Choose image' })
.click()
await uploadButton.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// The row shows the uploaded file's base name and a remove button
await expect(
backgroundImageSetting.getByText('image32x32.webp')
).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeVisible()
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
// The setting value points at the uploaded file
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should show the base name of an existing background image', async ({
test('should accept URL input for background image', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
'/api/view?filename=backgrounds%2Ftest-image.png&type=input&subfolder=backgrounds'
)
const testImageUrl = 'https://example.com/test-image.png'
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await expect(
backgroundImageSetting.getByText('test-image.png')
).toBeVisible()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image with the remove button', async ({
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.settings.setSetting(
'Comfy.Canvas.BackgroundImage',
'/api/view?filename=test-image.png&type=input'
testImageUrl
)
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
await backgroundImageSetting
.getByRole('button', { name: 'Remove image' })
.click()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Placeholder returns, remove button disappears, setting cleared
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
await expect(
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
).toBeHidden()
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.Canvas.BackgroundImage'))
.toBe('')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.getByRole('textbox')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
})
test('should show tooltip on upload and clear buttons', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.getByRole('button', {
name: /upload/i
})
await uploadButton.hover()
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
// Set a background to enable clear button
const urlInput = backgroundImageSetting.getByRole('textbox')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
await clearButton.hover()
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.getByRole('textbox')
const clearButton = backgroundImageSetting.getByRole('button', {
name: /clear/i
})
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
})
})

View File

@@ -292,10 +292,6 @@ test.describe('Node library sidebar', () => {
const dialog = comfyPage.page.getByRole('dialog', {
name: 'Customize Folder'
})
// Capture the dialog header position before opening the modal color
// picker: while the picker is open it sets aria-hidden on the dialog,
// so it can no longer be located by role.
const dialogBox = await dialog.boundingBox()
await dialog
.locator('.color-customization-selector-container > button')
.last()
@@ -304,17 +300,6 @@ test.describe('Node library sidebar', () => {
.getByLabel('Color saturation and brightness')
.click({ position: { x: 10, y: 10 } })
// The color picker popover is modal: while it is open the rest of the
// dialog is inert (pointer-events disabled), so dismiss it before
// interacting with other controls. A coordinate click on the dialog
// header lands on the dismiss layer and closes the popover.
if (dialogBox) {
await comfyPage.page.mouse.click(dialogBox.x + 40, dialogBox.y + 16)
}
await expect(
comfyPage.page.getByLabel('Color saturation and brightness')
).toBeHidden()
// Select Folder icon (2nd button in Icon group)
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
await iconGroup.getByRole('button').nth(1).click()

View File

@@ -111,7 +111,6 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,15 +591,7 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -22,7 +22,7 @@
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
"CLEAR_BACKGROUND_COLOR": "#222222",
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_COLOR": "#AAA",

View File

@@ -1,23 +1,58 @@
<template>
<ImageUpload
v-model="modelValue"
:loading="isUploading"
@file-selected="handleFileUpload"
/>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
variant="secondary"
size="sm"
:aria-label="$t('g.upload')"
:disabled="isUploading"
@click="triggerFileInput"
>
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
</Button>
<Button
v-tooltip="$t('g.clear')"
variant="destructive"
size="sm"
:aria-label="$t('g.clear')"
:disabled="!modelValue"
@click="clearImage"
>
<i class="pi pi-trash" />
</Button>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import ImageUpload from '@/components/ui/image-upload/ImageUpload.vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
@@ -39,24 +74,36 @@ const uploadFile = async (file: File): Promise<string | null> => {
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (file: File) => {
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>

View File

@@ -133,8 +133,8 @@
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="cursor-pointer text-blue-500">
hello@comfy.org</a
<a href="mailto:support@comfy.org" class="cursor-pointer text-blue-500">
support@comfy.org</a
>.
</p>
</template>

View File

@@ -376,17 +376,13 @@ watch(
)
watch(
[
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
() => settingStore.get('Comfy.Canvas.BackgroundPattern'),
() => settingStore.get('Comfy.Canvas.BackgroundColor')
],
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image/pattern/color
// Reload color palette to apply background image
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)

View File

@@ -23,8 +23,6 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -88,7 +86,7 @@
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -119,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -141,11 +130,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -1,47 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -1,21 +0,0 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,7 +52,6 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -82,7 +81,6 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -131,16 +129,12 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="showBackgroundImage && !hasBackgroundImage">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,16 +83,12 @@
</div>
<PopupSlider
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -118,9 +114,8 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,12 +1,10 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import Button from '@/components/ui/button/Button.vue'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
@@ -14,9 +12,6 @@ import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { CanvasBackgroundPattern } from '@/utils/canvasPatternUtil'
import { getEffectiveCanvasBackgroundColor } from '@/utils/canvasPatternUtil'
import { cn } from '@comfyorg/tailwind-utils'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -45,77 +40,6 @@ const nodes2Enabled = computed({
})
// CANVAS settings
const colorPaletteStore = useColorPaletteStore()
type CanvasBackgroundMode = CanvasBackgroundPattern | 'image'
// Keeps the Image option selected while no image is set, e.g. before the
// first upload or right after removing the current one.
const imageModeSelected = ref(false)
const backgroundImage = computed({
get: () => settingStore.get('Comfy.Canvas.BackgroundImage') ?? '',
set: (value) => {
if (!value) imageModeSelected.value = true
settingStore.set('Comfy.Canvas.BackgroundImage', value)
}
})
const isBackgroundImageSet = computed(() => !!backgroundImage.value)
const backgroundMode = computed<CanvasBackgroundMode>({
get: () =>
isBackgroundImageSet.value || imageModeSelected.value
? 'image'
: settingStore.get('Comfy.Canvas.BackgroundPattern'),
set: (value) => {
if (value === 'image') {
imageModeSelected.value = true
return
}
imageModeSelected.value = false
if (isBackgroundImageSet.value) {
settingStore.set('Comfy.Canvas.BackgroundImage', '')
}
settingStore.set('Comfy.Canvas.BackgroundPattern', value)
}
})
const backgroundOptions = computed(() => [
{
value: 'dots',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Dots')
},
{
value: 'grid',
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Grid')
},
{ value: 'none', label: t('g.none') },
{ value: 'image', label: t('rightSidePanel.globalSettings.image') }
])
const hasCustomBackgroundColor = computed(
() => settingStore.get('Comfy.Canvas.BackgroundColor') !== ''
)
const backgroundColor = computed({
get: () =>
getEffectiveCanvasBackgroundColor(
settingStore.get('Comfy.Canvas.BackgroundColor'),
colorPaletteStore.completedActivePalette.colors.litegraph_base
.CLEAR_BACKGROUND_COLOR
),
set: (value) =>
settingStore.set(
'Comfy.Canvas.BackgroundColor',
value.replace(/^#/, '').slice(0, 6)
)
})
async function resetBackgroundColor() {
await settingStore.set('Comfy.Canvas.BackgroundColor', '')
}
const gridSpacing = computed({
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
@@ -204,55 +128,6 @@ function openFullSettings() {
{{ t('rightSidePanel.globalSettings.canvas') }}
</template>
<div class="space-y-4 px-4 py-3">
<LayoutField
:label="t('rightSidePanel.globalSettings.background')"
:tooltip="t('settings.Comfy_Canvas_BackgroundPattern.tooltip')"
>
<Select
v-model="backgroundMode"
:options="backgroundOptions"
:aria-label="t('rightSidePanel.globalSettings.background')"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
option-label="label"
option-value="value"
/>
</LayoutField>
<BackgroundImageUpload
v-if="backgroundMode === 'image'"
v-model="backgroundImage"
/>
<LayoutField
v-else
:label="t('rightSidePanel.globalSettings.backgroundColor')"
:tooltip="t('settings.Comfy_Canvas_BackgroundColor.tooltip')"
>
<div class="flex items-center gap-1">
<ColorPicker
v-model="backgroundColor"
class="min-w-0 grow"
:aria-label="t('rightSidePanel.globalSettings.backgroundColor')"
/>
<Button
v-if="hasCustomBackgroundColor"
variant="muted-textonly"
size="icon"
:aria-label="
t('rightSidePanel.globalSettings.resetBackgroundColor')
"
@click="resetBackgroundColor"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
</Button>
</div>
</LayoutField>
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
<div
:class="

View File

@@ -1,34 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ColorPicker from './ColorPicker.vue'
describe('ColorPicker', () => {
it('does not echo a write back when the model is changed externally', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
})
it('shows the latest external color without writing back', async () => {
const onUpdate = vi.fn()
const { rerender } = render(ColorPicker, {
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
})
await rerender({ modelValue: '#222222' })
await nextTick()
await nextTick()
expect(onUpdate).not.toHaveBeenCalled()
expect(screen.getByText('#222222')).toBeTruthy()
})
})

View File

@@ -5,7 +5,6 @@ import {
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ZIndex } from '@primeuix/utils/zindex'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
@@ -24,15 +23,9 @@ const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
// Guard against echoing external model changes back: hex -> hsva -> hex is
// not an identity (rounding), so without the flag an outside write (e.g.
// resetting a setting to '') would immediately be overwritten.
let syncingFromModel = false
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
syncingFromModel = true
hsva.value = hexToHsva(newVal || '#000000')
}
})
@@ -40,10 +33,6 @@ watch(modelValue, (newVal) => {
watch(
hsva,
(newHsva) => {
if (syncingFromModel) {
syncingFromModel = false
return
}
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
@@ -71,29 +60,10 @@ const previewColor = computed(() => {
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
// The popover portals to body, so a static z-index can lose to dialogs that
// take theirs from the shared PrimeVue ZIndex counter (see vRekaZIndex.ts).
// Reka copies the content's z-index onto its popper wrapper, so compute the
// content z-index from the same counter on each open to stack above
// whichever dialog opened the picker.
const BASE_POPOVER_Z_INDEX = 1700
const popoverZIndex = ref(BASE_POPOVER_Z_INDEX)
watch(isOpen, (open) => {
if (open) {
popoverZIndex.value = Math.max(
BASE_POPOVER_Z_INDEX,
ZIndex.getCurrent('modal') + 1
)
}
})
</script>
<template>
<!-- Modal so the click that dismisses the popover cannot fall through to
the canvas and start a drag-select marquee. -->
<PopoverRoot v-model:open="isOpen" modal>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
@@ -145,7 +115,7 @@ watch(isOpen, (open) => {
align="start"
:side-offset="7"
:collision-padding="10"
:style="{ zIndex: popoverZIndex }"
class="z-1700"
>
<ColorPickerPanel
v-model:hsva="hsva"

View File

@@ -1,68 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ImageUpload from './ImageUpload.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ImageUpload>> = {
title: 'Components/ImageUpload',
component: ImageUpload,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" />'
})
}
export const WithImage: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('/api/view?filename=mountain+lake.png&type=input')
return { url }
},
template: '<ImageUpload v-model="url" />'
})
}
export const Loading: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" loading />'
})
}
export const Disabled: Story = {
render: () => ({
components: { ImageUpload },
setup() {
const url = ref('')
return { url }
},
template: '<ImageUpload v-model="url" disabled />'
})
}

View File

@@ -1,72 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ImageUpload from './ImageUpload.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderImageUpload(props: Record<string, unknown> = {}) {
return render(ImageUpload, {
props,
global: { plugins: [i18n] }
})
}
describe('ImageUpload', () => {
it('shows a placeholder when no image is set', () => {
renderImageUpload({ modelValue: '' })
expect(screen.getByText('Choose image')).toBeTruthy()
expect(screen.queryByLabelText('Remove image')).toBeNull()
})
it('shows the image base name extracted from the URL', () => {
renderImageUpload({
modelValue:
'/api/view?filename=backgrounds%2Fmountain+lake.png&type=input'
})
expect(screen.getByText('mountain lake.png')).toBeTruthy()
})
it('opens the file browser when the row is clicked', async () => {
const user = userEvent.setup({ applyAccept: false })
renderImageUpload({ modelValue: '' })
const fileInput = screen.getByTestId<HTMLInputElement>('image-upload-input')
const clickSpy = vi.spyOn(fileInput, 'click')
await user.click(screen.getByText('Choose image'))
expect(clickSpy).toHaveBeenCalled()
})
it('emits fileSelected when a file is picked', async () => {
const user = userEvent.setup({ applyAccept: false })
const { emitted } = renderImageUpload({ modelValue: '' })
const file = new File(['x'], 'photo.png', { type: 'image/png' })
await user.upload(
screen.getByTestId<HTMLInputElement>('image-upload-input'),
file
)
expect(emitted('fileSelected')).toEqual([[file]])
})
it('clears the model when the remove button is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderImageUpload({
modelValue: '/api/view?filename=bg.png'
})
await user.click(screen.getByLabelText('Remove image'))
expect(emitted('update:modelValue')).toEqual([['']])
})
})

View File

@@ -1,122 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
const {
class: className,
disabled = false,
loading = false
} = defineProps<{
class?: string
disabled?: boolean
loading?: boolean
}>()
const modelValue = defineModel<string>({ default: '' })
const emit = defineEmits<{
fileSelected: [file: File]
}>()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const previewFailed = ref(false)
watch(modelValue, () => {
previewFailed.value = false
})
const imageBaseName = computed(() => {
if (!modelValue.value) return ''
try {
const url = new URL(modelValue.value, window.location.origin)
const filename =
url.searchParams.get('filename') ?? url.pathname.split('/').pop() ?? ''
return filename.split('/').pop() ?? ''
} catch {
return modelValue.value
}
})
function openFileBrowser() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) emit('fileSelected', file)
input.value = ''
}
function clearImage() {
modelValue.value = ''
}
</script>
<template>
<div
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg bg-component-node-widget-background hover:bg-component-node-widget-background-hovered',
(disabled || loading) && 'cursor-not-allowed opacity-50',
className
)
"
>
<button
type="button"
:disabled="disabled || loading"
class="flex h-full min-w-0 flex-1 cursor-pointer items-center border-none bg-transparent p-0 outline-none disabled:cursor-not-allowed"
@click="openFileBrowser"
>
<span class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="loading"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<img
v-else-if="modelValue && !previewFailed"
:src="modelValue"
alt=""
class="size-5 rounded-sm object-cover"
@error="previewFailed = true"
/>
<i v-else class="icon-[lucide--image] size-4 text-muted-foreground" />
</span>
<span
:class="
cn(
'min-w-0 flex-1 truncate text-left text-xs',
imageBaseName
? 'text-component-node-foreground'
: 'text-muted-foreground'
)
"
>
{{ imageBaseName || t('g.chooseImage') }}
</span>
</button>
<button
v-if="modelValue && !loading"
type="button"
:disabled="disabled"
:aria-label="t('g.removeImage')"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-component-node-foreground outline-none disabled:cursor-not-allowed"
@click="clearImage"
>
<i class="icon-[lucide--x] size-4" />
</button>
<input
ref="fileInput"
data-testid="image-upload-input"
type="file"
class="hidden"
accept="image/*"
@change="handleFileChange"
/>
</div>
</template>

View File

@@ -22,7 +22,6 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -414,10 +413,16 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const {
scene: imageData,
@@ -436,11 +441,16 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: '',
model_3d_info
}

View File

@@ -23,7 +23,6 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -59,7 +58,6 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -249,24 +247,6 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,8 +102,6 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -1,36 +0,0 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,12 +9,7 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -1,103 +0,0 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,7 +37,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -67,12 +66,6 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -204,7 +204,6 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"chooseImage": "Choose image",
"uploadAlreadyInProgress": "Upload already in progress",
"uploadTimedOut": "Upload timed out. Please try again.",
"capture": "capture",
@@ -3597,10 +3596,6 @@
"showInfoBadges": "Show info badges",
"showToolbox": "Show toolbox on selection",
"nodes2": "Nodes 2.0",
"background": "Background",
"image": "Image",
"backgroundColor": "Background color",
"resetBackgroundColor": "Reset background color to theme default",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"linkShape": "Link shape",

View File

@@ -29,23 +29,10 @@
"name": "Disable animations",
"tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation."
},
"Comfy_Canvas_BackgroundColor": {
"name": "Canvas background color",
"tooltip": "Custom canvas background color. Leave empty to follow the active theme."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_BackgroundPattern": {
"name": "Canvas background pattern",
"tooltip": "Pattern drawn on the canvas background. Not shown while a custom background image is set.",
"options": {
"Dots": "Dots",
"Grid": "Grid",
"None": "None"
}
},
"Comfy_Canvas_LeftMouseClickBehavior": {
"name": "Left Mouse Click Behavior",
"options": {

View File

@@ -22,7 +22,7 @@ const zAsset = z.object({
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true, next_cursor: true })
.pick({ total: true, has_more: true })
.extend({
assets: z.array(zAsset)
})

View File

@@ -53,7 +53,6 @@ const fetchApiMock = vi.mocked(api.fetchApi)
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
nextCursor?: AssetResponse['next_cursor']
}
function buildResponse(
@@ -69,18 +68,9 @@ function buildResponse(
function buildAssetListResponse(
assets: AssetItem[],
{
hasMore = false,
total = assets.length,
nextCursor
}: AssetListResponseOptions = {}
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
): Response {
return buildResponse({
assets,
total,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -522,7 +512,7 @@ describe(assetService.getAllAssetsByTag, () => {
vi.clearAllMocks()
})
it('walks pages by keyset cursor with include_public=true', async () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
@@ -530,7 +520,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-page-2' }
{ hasMore: true }
)
)
.mockResolvedValueOnce(
@@ -548,8 +538,6 @@ describe(assetService.getAllAssetsByTag, () => {
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
// First page carries neither a cursor nor an offset.
expect(firstParams.has('after')).toBe(false)
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
@@ -557,9 +545,7 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
// Subsequent pages resume from the prior response's next_cursor, never offset.
expect(secondParams.get('after')).toBe('cursor-page-2')
expect(secondParams.has('offset')).toBe(false)
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
@@ -570,7 +556,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-next' }
{ hasMore: true }
)
)
.mockResolvedValueOnce(
@@ -591,45 +577,7 @@ describe(assetService.getAllAssetsByTag, () => {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('after')).toBe('cursor-next')
})
it('stops walking when next_cursor is absent even if has_more is true', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
hasMore: true
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['only'])
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('stops walking when the server returns a non-advancing cursor', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 1
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
expect(secondParams.get('offset')).toBe('2')
})
it.for([
@@ -688,7 +636,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-page-2' }
{ hasMore: true }
)
})

View File

@@ -31,11 +31,6 @@ export interface PaginationOptions {
}
interface AssetPaginationOptions extends PaginationOptions {
/**
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
* server resumes after that cursor and `offset` is ignored.
*/
after?: string
signal?: AbortSignal
}
@@ -43,7 +38,6 @@ interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
after?: string
signal?: AbortSignal
}
@@ -292,7 +286,6 @@ function createAssetService() {
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
after,
includePublic,
signal
} = options
@@ -306,11 +299,7 @@ function createAssetService() {
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
if (after) {
queryParams.set('after', after)
} else if (offset !== undefined && offset > 0) {
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
@@ -492,17 +481,11 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await getAssetsPageByTag(tag, includePublic, {
limit,
offset,
after,
signal
})
@@ -515,27 +498,17 @@ function createAssetService() {
async function getAssetsPageByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise<AssetResponse> {
return await handleAssetRequest(
{ includeTags: [tag], limit, offset, after, includePublic, signal },
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
*
* Uses keyset (cursor) pagination: each page is fetched with the prior
* response's `next_cursor`, which is stable under concurrent inserts/deletes
* and avoids the duplicate/skip drift that offset paging exhibits when the
* underlying set changes mid-walk. Falls back to terminating on `has_more`
* when the server omits `next_cursor`.
* Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -547,21 +520,18 @@ function createAssetService() {
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
signal
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let after: string | undefined
let offset = 0
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await getAssetsPageByTag(tag, includePublic, {
limit: pageSize,
after,
offset,
signal
})
const batch = data.assets
@@ -571,12 +541,11 @@ function createAssetService() {
assets.push(...batch)
// A server that returns a non-advancing cursor would loop forever.
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
if (!data.has_more) {
return assets
}
after = data.next_cursor
offset += batch.length
}
}

View File

@@ -1136,31 +1136,6 @@ export const CORE_SETTINGS: SettingParams[] = [
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'Comfy.Canvas.BackgroundPattern',
category: ['Appearance', 'Canvas', 'BackgroundPattern'],
name: 'Canvas background pattern',
type: 'combo',
options: [
{ value: 'dots', text: 'Dots' },
{ value: 'grid', text: 'Grid' },
{ value: 'none', text: 'None' }
],
tooltip:
'Pattern drawn on the canvas background. Not shown while a custom background image is set.',
defaultValue: 'dots',
versionAdded: '1.47.1'
},
{
id: 'Comfy.Canvas.BackgroundColor',
category: ['Appearance', 'Canvas', 'BackgroundColor'],
name: 'Canvas background color',
type: 'color',
tooltip:
'Custom canvas background color. Leave empty to follow the active theme.',
defaultValue: '',
versionAdded: '1.47.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',

View File

@@ -53,8 +53,7 @@ describe('useReleaseService', () => {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined,
headers: undefined
signal: undefined
})
expect(result).toEqual(mockReleases)
@@ -77,8 +76,7 @@ describe('useReleaseService', () => {
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined,
headers: undefined
signal: undefined
})
expect(result).toEqual(mockReleases)
@@ -88,30 +86,11 @@ describe('useReleaseService', () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ signal: abortController.signal }
)
await service.getReleases({ project: 'comfyui' }, abortController.signal)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal,
headers: undefined
})
})
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ deployEnvironment: 'local-desktop' }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: undefined,
headers: { 'Comfy-Env': 'local-desktop' }
signal: abortController.signal
})
})

View File

@@ -98,9 +98,8 @@ export const useReleaseService = () => {
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
signal?: AbortSignal
): Promise<ReleaseNote[] | null> => {
const { signal, deployEnvironment } = options
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
@@ -111,10 +110,7 @@ export const useReleaseService = () => {
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
signal
}),
errorContext,
routeSpecificErrors

View File

@@ -228,15 +228,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
})
})
@@ -303,15 +300,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(store.releases).toEqual([mockRelease])
})
@@ -324,30 +318,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
it('should pass deploy_environment from system stats', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
expect.anything(),
{ deployEnvironment: 'local-desktop' }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
})
})
it('should skip fetching when --disable-api-nodes is present', async () => {

View File

@@ -266,18 +266,12 @@ export const useReleaseStore = defineStore('release', () => {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases(
{
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
},
{
deployEnvironment:
systemStatsStore.systemStats?.system?.deploy_environment
}
)
const fetchedReleases = await releaseService.getReleases({
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
if (fetchedReleases !== null) {
releases.value = fetchedReleases

View File

@@ -51,9 +51,6 @@ const AudioPreviewPlayer = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const Load3DAdvanced = defineAsyncComponent(
() => import('@/components/load3d/Load3DAdvanced.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
@@ -172,14 +169,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'load3DAdvanced',
{
component: Load3DAdvanced,
aliases: ['LOAD_3D_ADVANCED'],
essential: false
}
],
[
'imagecrop',
{
@@ -254,7 +243,6 @@ const EXPANDING_TYPES = [
'textarea',
'markdown',
'load3D',
'load3DAdvanced',
'curve',
'painter',
'imagecompare',

View File

@@ -252,7 +252,6 @@ const zSystemStats = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
@@ -308,8 +307,6 @@ const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.Canvas.BackgroundPattern': z.enum(['dots', 'grid', 'none']),
'Comfy.Canvas.BackgroundColor': z.string(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Appearance.DisableAnimations': z.boolean(),

View File

@@ -108,11 +108,6 @@ interface QueuePromptRequestBody {
* ```
*/
api_key_comfy_org?: string
/**
* Identifies the client submitting the prompt. Forwarded by the backend
* to API nodes' upstream requests via the Comfy-Usage-Source header.
*/
comfy_usage_source?: string
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting.
@@ -872,7 +867,6 @@ export class ComfyApi extends EventTarget {
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
comfy_usage_source: 'comfyui-frontend',
extra_pnginfo: { workflow },
...(options?.previewMethod &&
options.previewMethod !== 'default' && {

View File

@@ -1,90 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as canvasPatternUtil from '@/utils/canvasPatternUtil'
const mockSettings = vi.hoisted(() => ({
values: {} as Record<string, unknown>
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
background_image: 'initial',
clear_background_color: 'initial',
_pattern: 'initial',
node_title_color: '',
default_link_color: '',
default_connection_color_byType: {},
setDirty: vi.fn()
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => mockSettings.values[key],
set: vi.fn()
})
}))
vi.mock('@/utils/canvasPatternUtil', async (importOriginal) => ({
...(await importOriginal<typeof canvasPatternUtil>()),
generateCanvasPatternImage: vi.fn(
(pattern: string, color: string) => `tile:${pattern}:${color}`
)
}))
import { app } from '@/scripts/app'
import { useColorPaletteService } from '@/services/colorPaletteService'
describe('colorPaletteService canvas background', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockSettings.values = {
'Comfy.Canvas.BackgroundImage': '',
'Comfy.Canvas.BackgroundPattern': 'dots',
'Comfy.Canvas.BackgroundColor': ''
}
app.canvas.background_image = 'initial'
app.canvas.clear_background_color = 'initial'
app.canvas._pattern = 'initial' as unknown as undefined
})
it('renders the generated pattern over the palette color by default', async () => {
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:dots:#222222')
expect(app.canvas.clear_background_color).toBe('#222222')
expect(app.canvas._pattern).toBeUndefined()
})
it('uses the user background color when set', async () => {
mockSettings.values['Comfy.Canvas.BackgroundColor'] = 'aabbcc'
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'grid'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:grid:#aabbcc')
expect(app.canvas.clear_background_color).toBe('#aabbcc')
})
it('renders a solid tile when the pattern is none', async () => {
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'none'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('tile:none:#222222')
})
it('lets a custom background image take precedence over patterns', async () => {
mockSettings.values['Comfy.Canvas.BackgroundImage'] = 'https://bg.png'
await useColorPaletteService().loadColorPalette('dark')
expect(app.canvas.background_image).toBe('')
expect(app.canvas.clear_background_color).toBe('transparent')
expect(app.canvas._pattern).toBeUndefined()
})
})

View File

@@ -11,10 +11,6 @@ import type { Colors, Palette } from '@/schemas/colorPaletteSchema'
import { app } from '@/scripts/app'
import { uploadFile } from '@/scripts/utils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
generateCanvasPatternImage,
getEffectiveCanvasBackgroundColor
} from '@/utils/canvasPatternUtil'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const THEME_PROPERTY_MAP = {
@@ -157,18 +153,10 @@ export const useColorPaletteService = () => {
app.canvas.default_link_color = palette.LINK_COLOR
const backgroundImage = settingStore.get('Comfy.Canvas.BackgroundImage')
if (backgroundImage) {
app.canvas.background_image = ''
app.canvas.clear_background_color = 'transparent'
} else {
const backgroundColor = getEffectiveCanvasBackgroundColor(
settingStore.get('Comfy.Canvas.BackgroundColor'),
palette.CLEAR_BACKGROUND_COLOR
)
app.canvas.background_image = generateCanvasPatternImage(
settingStore.get('Comfy.Canvas.BackgroundPattern'),
backgroundColor
)
app.canvas.clear_background_color = backgroundColor
app.canvas.background_image = palette.BACKGROUND_IMAGE
app.canvas.clear_background_color = palette.CLEAR_BACKGROUND_COLOR
}
app.canvas._pattern = undefined

View File

@@ -1,135 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
generateCanvasPatternImage,
getEffectiveCanvasBackgroundColor,
getPatternMarkColor
} from '@/utils/canvasPatternUtil'
interface RecordingContext {
fillStyle: string
strokeStyle: string
lineWidth: number
groundColor: string
fillRect: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
arc: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
}
function createRecordingContext(): RecordingContext {
const ctx: RecordingContext = {
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
groundColor: '',
fillRect: vi.fn(() => {
ctx.groundColor = ctx.fillStyle
}),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn()
}
return ctx
}
let contexts: RecordingContext[]
let getContextSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
contexts = []
getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockImplementation(() => {
const ctx = createRecordingContext()
contexts.push(ctx)
return ctx as unknown as GPUCanvasContext
})
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,sentinel'
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('getPatternMarkColor', () => {
it('uses faint light marks on dark backgrounds', () => {
expect(getPatternMarkColor('#141414')).toBe('rgba(255, 255, 255, 0.10)')
expect(getPatternMarkColor('#0000ff')).toBe('rgba(255, 255, 255, 0.10)')
})
it('uses faint dark marks on light backgrounds', () => {
expect(getPatternMarkColor('#f0f0f0')).toBe('rgba(0, 0, 0, 0.13)')
expect(getPatternMarkColor('#ffffff')).toBe('rgba(0, 0, 0, 0.13)')
})
})
describe('getEffectiveCanvasBackgroundColor', () => {
it('prefers the user setting over the palette color', () => {
expect(getEffectiveCanvasBackgroundColor('aabbcc', '#222222')).toBe(
'#aabbcc'
)
})
it('falls back to the palette color when the setting is empty', () => {
expect(getEffectiveCanvasBackgroundColor('', '#222222')).toBe('#222222')
})
it('accepts a #-prefixed stored value', () => {
expect(getEffectiveCanvasBackgroundColor('#aabbcc', '#222222')).toBe(
'#aabbcc'
)
})
})
describe('generateCanvasPatternImage', () => {
it('fills the ground with the background color and draws no marks for none', () => {
generateCanvasPatternImage('none', '#101010')
const ctx = contexts.at(-1)!
expect(ctx.fillRect).toHaveBeenCalledExactlyOnceWith(0, 0, 100, 100)
expect(ctx.groundColor).toBe('#101010')
expect(ctx.arc).not.toHaveBeenCalled()
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('draws a 5x5 grid of dots for the dots pattern', () => {
generateCanvasPatternImage('dots', '#111111')
const ctx = contexts.at(-1)!
expect(ctx.arc).toHaveBeenCalledTimes(25)
expect(ctx.fill).toHaveBeenCalledTimes(25)
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('draws 5 vertical and 5 horizontal lines for the grid pattern', () => {
generateCanvasPatternImage('grid', '#121212')
const ctx = contexts.at(-1)!
expect(ctx.stroke).toHaveBeenCalledTimes(10)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('returns a data URI', () => {
expect(generateCanvasPatternImage('dots', '#131313')).toBe(
'data:image/png;base64,sentinel'
)
})
it('strips alpha from 8-digit hex backgrounds', () => {
generateCanvasPatternImage('dots', '#101010ff')
expect(contexts.at(-1)!.groundColor).toBe('#101010')
})
it('memoizes tiles per pattern and color', () => {
generateCanvasPatternImage('grid', '#151515')
const callsAfterFirst = getContextSpy.mock.calls.length
generateCanvasPatternImage('grid', '#151515')
expect(getContextSpy.mock.calls.length).toBe(callsAfterFirst)
})
})

View File

@@ -1,120 +0,0 @@
import { memoize } from 'es-toolkit/compat'
import { isLightColor, parseToRgb, rgbToHex } from '@/utils/colorUtil'
export type CanvasBackgroundPattern = 'dots' | 'grid' | 'none'
const TILE_SIZE = 100
const SPACING = 20
const DOT_RADIUS = 1.25
const GRID_LINE_WIDTH = 1
const LIGHT_MARK_COLOR = 'rgba(255, 255, 255, 0.10)'
const DARK_MARK_COLOR = 'rgba(0, 0, 0, 0.13)'
let sharedContext: CanvasRenderingContext2D | null = null
function getSharedContext(): CanvasRenderingContext2D {
sharedContext ??= document.createElement('canvas').getContext('2d')
if (!sharedContext) throw new Error('2D canvas context unavailable')
return sharedContext
}
/**
* Normalizes any CSS color (hex, rgb()/hsl(), named colors like `lightgray`)
* to opaque lowercase `#rrggbb`.
*/
function normalizeToHexColor(color: string): string {
const trimmed = color.trim()
if (trimmed.startsWith('#')) {
return rgbToHex(parseToRgb(trimmed)).toLowerCase()
}
const ctx = getSharedContext()
ctx.fillStyle = '#000000'
ctx.fillStyle = trimmed
const parsed = ctx.fillStyle
return parsed.startsWith('#')
? parsed.toLowerCase()
: rgbToHex(parseToRgb(parsed)).toLowerCase()
}
/**
* Resolves the canvas background color: a user-set value (stored as hex
* without `#`) wins over the active palette's color.
*/
export function getEffectiveCanvasBackgroundColor(
settingValue: string,
paletteColor: string
): string {
return normalizeToHexColor(
settingValue ? `#${settingValue.replace(/^#/, '')}` : paletteColor
)
}
/** Faint mark color auto-contrasted against the background. */
export function getPatternMarkColor(backgroundColor: string): string {
return isLightColor(normalizeToHexColor(backgroundColor))
? DARK_MARK_COLOR
: LIGHT_MARK_COLOR
}
function drawDots(ctx: CanvasRenderingContext2D) {
for (let x = SPACING / 2; x < TILE_SIZE; x += SPACING) {
for (let y = SPACING / 2; y < TILE_SIZE; y += SPACING) {
ctx.beginPath()
ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2)
ctx.fill()
}
}
}
function drawGrid(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = GRID_LINE_WIDTH
for (let offset = 0; offset < TILE_SIZE; offset += SPACING) {
ctx.beginPath()
ctx.moveTo(offset + 0.5, 0)
ctx.lineTo(offset + 0.5, TILE_SIZE)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(0, offset + 0.5)
ctx.lineTo(TILE_SIZE, offset + 0.5)
ctx.stroke()
}
}
function renderPatternImage(
pattern: CanvasBackgroundPattern,
backgroundColor: string
): string {
const canvas = document.createElement('canvas')
canvas.width = TILE_SIZE
canvas.height = TILE_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D canvas context unavailable')
const ground = normalizeToHexColor(backgroundColor)
ctx.fillStyle = ground
ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE)
const markColor = getPatternMarkColor(ground)
ctx.fillStyle = markColor
ctx.strokeStyle = markColor
if (pattern === 'dots') drawDots(ctx)
if (pattern === 'grid') drawGrid(ctx)
return canvas.toDataURL('image/png')
}
/**
* Generates an opaque repeating background tile as a data URI. The tile is
* always opaque (including `none`) because LGraphCanvas paints only the tile
* at zoom levels >= 1.5.
*/
export const generateCanvasPatternImage: (
pattern: CanvasBackgroundPattern,
backgroundColor: string
) => string = memoize(
renderPatternImage,
(pattern: CanvasBackgroundPattern, backgroundColor: string) =>
`${pattern}:${backgroundColor}`
)

View File

@@ -3,13 +3,11 @@ import { describe, expect, it, vi } from 'vitest'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
adjustColor,
getRelativeLuminance,
hexToHsva,
hexToInt,
hexToRgb,
hsbToRgb,
hsvaToHex,
isLightColor,
isTransparent,
parseToRgb,
rgbToHex,
@@ -412,28 +410,3 @@ describe('colorUtil - adjustColor', () => {
})
})
})
describe('colorUtil - luminance', () => {
describe('getRelativeLuminance', () => {
it('returns 0 for black and 1 for white', () => {
expect(getRelativeLuminance('#000000')).toBe(0)
expect(getRelativeLuminance('#ffffff')).toBeCloseTo(1)
})
it('returns the WCAG luminance for mid gray', () => {
expect(getRelativeLuminance('#808080')).toBeCloseTo(0.2159, 3)
})
})
describe('isLightColor', () => {
it('classifies dark backgrounds as dark', () => {
expect(isLightColor('#141414')).toBe(false)
expect(isLightColor('#0000ff')).toBe(false)
})
it('classifies light backgrounds as light', () => {
expect(isLightColor('#f0f0f0')).toBe(true)
expect(isLightColor('#808080')).toBe(true)
})
})
})

View File

@@ -404,26 +404,6 @@ export function hsvaToHex(hsva: HSVA): string {
return `${hex}${alphaHex}`.toLowerCase()
}
function linearizeSrgbChannel(channel: number): number {
const c = channel / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
/** WCAG relative luminance (0..1) of a parseable CSS color. */
export function getRelativeLuminance(color: string): number {
const { r, g, b } = parseToRgb(color)
return (
0.2126 * linearizeSrgbChannel(r) +
0.7152 * linearizeSrgbChannel(g) +
0.0722 * linearizeSrgbChannel(b)
)
}
/** True when dark foreground marks should be used on this background. */
export function isLightColor(color: string): boolean {
return getRelativeLuminance(color) > 0.179
}
const applyColorAdjustments = (
color: string,
options: ColorAdjustOptions