mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-12 01:09:36 +00:00
Compare commits
1 Commits
feat/nativ
...
glary/supp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3af003224 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />'
|
||||
})
|
||||
}
|
||||
@@ -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([['']])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' && {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user