Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}
@@ -50,10 +95,10 @@ export const Default: Story = {
}
export const WithPreselectedValues: Story = {
- render: () => ({
+ render: (args) => ({
components: { MultiSelect },
setup() {
- const options = [
+ const options = args.options || [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
@@ -61,25 +106,43 @@ export const WithPreselectedValues: Story = {
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
- return { selected, options }
+ return { selected, options, args }
},
template: `
Selected: {{ selected.map(s => s.name).join(', ') }}
`
- })
+ }),
+ args: {
+ label: 'Select Languages',
+ options: [
+ { name: 'JavaScript', value: 'js' },
+ { name: 'TypeScript', value: 'ts' },
+ { name: 'Python', value: 'python' },
+ { name: 'Go', value: 'go' },
+ { name: 'Rust', value: 'rust' }
+ ],
+ showSearchBox: false,
+ showSelectedCount: false,
+ showClearButton: false,
+ searchPlaceholder: 'Search...'
+ }
}
export const MultipleSelectors: Story = {
- render: () => ({
+ render: (args) => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
@@ -114,7 +177,8 @@ export const MultipleSelectors: Story = {
tagOptions,
selectedFrameworks,
selectedProjects,
- selectedTags
+ selectedTags,
+ args
}
},
template: `
@@ -124,22 +188,34 @@ export const MultipleSelectors: Story = {
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
+ :showSearchBox="args.showSearchBox"
+ :showSelectedCount="args.showSelectedCount"
+ :showClearButton="args.showClearButton"
+ :searchPlaceholder="args.searchPlaceholder"
/>
-
Current Selection:
-
+
Current Selection:
+
Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}
Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}
Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}
@@ -147,5 +223,54 @@ export const MultipleSelectors: Story = {
`
- })
+ }),
+ args: {
+ showSearchBox: false,
+ showSelectedCount: false,
+ showClearButton: false,
+ searchPlaceholder: 'Search...'
+ }
+}
+
+export const WithSearchBox: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ showSearchBox: true
+ }
+}
+
+export const WithSelectedCount: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ showSelectedCount: true
+ }
+}
+
+export const WithClearButton: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ showClearButton: true
+ }
+}
+
+export const AllHeaderFeatures: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ showSearchBox: true,
+ showSelectedCount: true,
+ showClearButton: true
+ }
+}
+
+export const CustomSearchPlaceholder: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ showSearchBox: true,
+ searchPlaceholder: 'Filter packages...'
+ }
}
diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue
index d1d02a4f16..1dbb53b464 100644
--- a/src/components/input/MultiSelect.vue
+++ b/src/components/input/MultiSelect.vue
@@ -1,93 +1,104 @@
-
-
+
+
-
-
-
+
+
+
+ {{
+ selectedCount > 0
+ ? $t('g.itemsSelected', { selectedCount })
+ : $t('g.itemSelected', { selectedCount })
+ }}
+
+
-
-
- {{
- selectedCount > 0
- ? $t('g.itemsSelected', { selectedCount })
- : $t('g.itemSelected', { selectedCount })
- }}
-
-
-
-
-
+
+
+
-
-
-
- {{ label }}
-
-
+
+
+
+ {{ label }}
+
+
+ {{ selectedCount }}
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
{{ slotProps.option.name }}
+
+
+
+
+
-
-
-
-
-
- {{ selectedCount }}
-
-
+
+
+
+
diff --git a/src/components/input/SingleSelect.stories.ts b/src/components/input/SingleSelect.stories.ts
index ba802197c6..c0a2798224 100644
--- a/src/components/input/SingleSelect.stories.ts
+++ b/src/components/input/SingleSelect.stories.ts
@@ -4,12 +4,24 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
+// SingleSelect already includes options prop, so no need to extend
const meta: Meta = {
title: 'Components/Input/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
argTypes: {
- label: { control: 'text' }
+ label: { control: 'text' },
+ options: { control: 'object' }
+ },
+ args: {
+ label: 'Sorting Type',
+ options: [
+ { name: 'Popular', value: 'popular' },
+ { name: 'Newest', value: 'newest' },
+ { name: 'Oldest', value: 'oldest' },
+ { name: 'A → Z', value: 'az' },
+ { name: 'Z → A', value: 'za' }
+ ]
}
}
@@ -29,19 +41,18 @@ export const Default: Story = {
components: { SingleSelect },
setup() {
const selected = ref(null)
- const options = sampleOptions
+ const options = args.options || sampleOptions
return { selected, options, args }
},
template: `
-
+
Selected: {{ selected ?? 'None' }}
`
- }),
- args: { label: 'Sorting Type' }
+ })
}
export const WithIcon: Story = {
diff --git a/src/components/input/SingleSelect.vue b/src/components/input/SingleSelect.vue
index 9ef74bacd7..7687a16ea1 100644
--- a/src/components/input/SingleSelect.vue
+++ b/src/components/input/SingleSelect.vue
@@ -1,58 +1,73 @@
-
-
+
+
+
+ {{ option.name }}
+
+
+
+
diff --git a/src/components/widget/layout/BaseWidget.stories.ts b/src/components/widget/layout/BaseWidget.stories.ts
index 31e988cb27..0ac0341dea 100644
--- a/src/components/widget/layout/BaseWidget.stories.ts
+++ b/src/components/widget/layout/BaseWidget.stories.ts
@@ -240,6 +240,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
v-model="selectedFrameworks"
label="Select Frameworks"
:options="frameworkOptions"
+ :has-search-box="true"
+ :show-selected-count="true"
+ :has-clear-button="true"
/>
{
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
+ const dialogService = useDialogService()
+ const { deleteAccount } = useFirebaseAuthActions()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
@@ -85,6 +90,18 @@ export const useCurrentUser = () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
+ const handleDeleteAccount = async () => {
+ const confirmed = await dialogService.confirm({
+ title: t('auth.deleteAccount.confirmTitle'),
+ message: t('auth.deleteAccount.confirmMessage'),
+ type: 'delete'
+ })
+
+ if (confirmed) {
+ await deleteAccount()
+ }
+ }
+
return {
loading: authStore.loading,
isLoggedIn,
@@ -96,6 +113,7 @@ export const useCurrentUser = () => {
providerName,
providerIcon,
handleSignOut,
- handleSignIn
+ handleSignIn,
+ handleDeleteAccount
}
}
diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts
index 466d0ef82f..ac0afecc23 100644
--- a/src/composables/auth/useFirebaseAuthActions.ts
+++ b/src/composables/auth/useFirebaseAuthActions.ts
@@ -135,6 +135,16 @@ export const useFirebaseAuthActions = () => {
reportError
)
+ const deleteAccount = wrapWithErrorHandlingAsync(async () => {
+ await authStore.deleteAccount()
+ toastStore.add({
+ severity: 'success',
+ summary: t('auth.deleteAccount.success'),
+ detail: t('auth.deleteAccount.successDetail'),
+ life: 5000
+ })
+ }, reportError)
+
return {
logout,
sendPasswordReset,
@@ -146,6 +156,7 @@ export const useFirebaseAuthActions = () => {
signInWithEmail,
signUpWithEmail,
updatePassword,
+ deleteAccount,
accessError
}
}
diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts
index 724937fa91..27355a2205 100644
--- a/src/composables/node/useNodePricing.ts
+++ b/src/composables/node/useNodePricing.ts
@@ -1,4 +1,4 @@
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
/**
@@ -179,6 +179,12 @@ const apiNodeCosts: Record =
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
+ const characterInput = node.inputs?.find(
+ (i) => i.name === 'character_image'
+ ) as INodeInputSlot
+ const hasCharacter =
+ typeof characterInput?.link !== 'undefined' &&
+ characterInput.link != null
if (!renderingSpeedWidget)
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
@@ -188,11 +194,23 @@ const apiNodeCosts: Record =
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
- basePrice = 0.09
- } else if (renderingSpeed.toLowerCase().includes('balanced')) {
- basePrice = 0.06
+ if (hasCharacter) {
+ basePrice = 0.2
+ } else {
+ basePrice = 0.09
+ }
+ } else if (renderingSpeed.toLowerCase().includes('default')) {
+ if (hasCharacter) {
+ basePrice = 0.15
+ } else {
+ basePrice = 0.06
+ }
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
- basePrice = 0.03
+ if (hasCharacter) {
+ basePrice = 0.1
+ } else {
+ basePrice = 0.03
+ }
}
const totalCost = (basePrice * numImages).toFixed(2)
@@ -395,7 +413,12 @@ const apiNodeCosts: Record =
const modeValue = String(modeWidget.value)
// Same pricing matrix as KlingTextToVideoNode
- if (modeValue.includes('v2-master')) {
+ if (modeValue.includes('v2-1')) {
+ if (modeValue.includes('10s')) {
+ return '$0.98/Run' // pro, 10s
+ }
+ return '$0.49/Run' // pro, 5s default
+ } else if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -1462,7 +1485,7 @@ export const useNodePricing = () => {
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
- IdeogramV3: ['rendering_speed', 'num_images'],
+ IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
VeoVideoGenerationNode: ['duration_seconds'],
diff --git a/src/composables/node/useWatchWidget.ts b/src/composables/node/useWatchWidget.ts
index f30325696e..e32cb01015 100644
--- a/src/composables/node/useWatchWidget.ts
+++ b/src/composables/node/useWatchWidget.ts
@@ -75,6 +75,29 @@ export const useComputedWithWidgetWatch = (
}
})
})
+ if (widgetNames && widgetNames.length > widgetsToObserve.length) {
+ //Inputs have been included
+ const indexesToObserve = widgetNames
+ .map((name) =>
+ widgetsToObserve.some((w) => w.name == name)
+ ? -1
+ : node.inputs.findIndex((i) => i.name == name)
+ )
+ .filter((i) => i >= 0)
+ node.onConnectionsChange = useChainCallback(
+ node.onConnectionsChange,
+ (_type: unknown, index: number, isConnected: boolean) => {
+ if (!indexesToObserve.includes(index)) return
+ widgetValues.value = {
+ ...widgetValues.value,
+ [indexesToObserve[index]]: isConnected
+ }
+ if (triggerCanvasRedraw) {
+ node.graph?.setDirtyCanvas(true, true)
+ }
+ }
+ )
+ }
}
// Returns a function that creates a computed that responds to widget changes.
diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts
index 4d078ecaa3..6217555efd 100644
--- a/src/composables/useCoreCommands.ts
+++ b/src/composables/useCoreCommands.ts
@@ -446,6 +446,9 @@ export function useCoreCommands(): ComfyCommand[] {
)
group.resizeTo(canvas.selectedItems, padding)
canvas.graph?.add(group)
+
+ group.recomputeInsideNodes()
+
useTitleEditorStore().titleEditorTarget = group
}
},
@@ -676,36 +679,13 @@ export function useCoreCommands(): ComfyCommand[] {
await workflowService.closeWorkflow(workflowStore.activeWorkflow)
}
},
- {
- id: 'Comfy.Feedback',
- icon: 'pi pi-megaphone',
- label: 'Give Feedback',
- versionAdded: '1.8.2',
- function: () => {
- dialogService.showIssueReportDialog({
- title: t('g.feedback'),
- subtitle: t('issueReport.feedbackTitle'),
- panelProps: {
- errorType: 'Feedback',
- defaultFields: ['SystemStats', 'Settings']
- }
- })
- }
- },
{
id: 'Comfy.ContactSupport',
icon: 'pi pi-question',
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
- dialogService.showIssueReportDialog({
- title: t('issueReport.contactSupportTitle'),
- subtitle: t('issueReport.contactSupportDescription'),
- panelProps: {
- errorType: 'ContactSupport',
- defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
- }
- })
+ window.open('https://support.comfy.org/', '_blank')
}
},
{
diff --git a/src/composables/useModelSelectorDialog.ts b/src/composables/useModelSelectorDialog.ts
index 70dc88bddb..4c1ecde0e1 100644
--- a/src/composables/useModelSelectorDialog.ts
+++ b/src/composables/useModelSelectorDialog.ts
@@ -1,4 +1,4 @@
-import ModelSelector from '@/components/widget/ModelSelector.vue'
+import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
- component: ModelSelector,
+ component: SampleModelSelector,
props: {
onClose: hide
}
diff --git a/src/constants/coreMenuCommands.ts b/src/constants/coreMenuCommands.ts
index 94668668a9..541916a1cf 100644
--- a/src/constants/coreMenuCommands.ts
+++ b/src/constants/coreMenuCommands.ts
@@ -13,6 +13,7 @@ export const CORE_MENU_COMMANDS = [
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.OpenClipspace']],
+ [['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Help'],
[
@@ -22,8 +23,5 @@ export const CORE_MENU_COMMANDS = [
'Comfy.Help.OpenComfyUIForum'
]
],
- [
- ['Help'],
- ['Comfy.Help.AboutComfyUI', 'Comfy.Feedback', 'Comfy.ContactSupport']
- ]
+ [['Help'], ['Comfy.Help.AboutComfyUI', 'Comfy.ContactSupport']]
]
diff --git a/src/extensions/core/groupOptions.ts b/src/extensions/core/groupOptions.ts
index 945f5bd904..297f625219 100644
--- a/src/extensions/core/groupOptions.ts
+++ b/src/extensions/core/groupOptions.ts
@@ -42,6 +42,8 @@ app.registerExtension({
this.graph.add(group)
// @ts-expect-error fixme ts strict error
this.graph.change()
+
+ group.recomputeInsideNodes()
}
})
}
diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts
index dc4e5baf50..ff9036723a 100644
--- a/src/extensions/core/maskeditor.ts
+++ b/src/extensions/core/maskeditor.ts
@@ -1,3 +1,4 @@
+import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
import _ from 'es-toolkit/compat'
@@ -9,6 +10,7 @@ import { ComfyApp } from '../../scripts/app'
import { $el, ComfyDialog } from '../../scripts/ui'
import { getStorageValue, setStorageValue } from '../../scripts/utils'
import { hexToRgb } from '../../utils/colorUtil'
+import { parseToRgb } from '../../utils/colorUtil'
import { ClipspaceDialog } from './clipspace'
import {
imageLayerFilenamesByTimestamp,
@@ -811,7 +813,7 @@ interface Offset {
y: number
}
-export interface Brush {
+interface Brush {
type: BrushShape
size: number
opacity: number
@@ -2049,9 +2051,16 @@ class BrushTool {
rgbCtx: CanvasRenderingContext2D | null = null
initialDraw: boolean = true
+ private static brushTextureCache = new QuickLRU({
+ maxSize: 8 // Reasonable limit for brush texture variations?
+ })
+
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
+ private static readonly SMOOTHING_MAX_STEPS = 30
+ private static readonly SMOOTHING_MIN_STEPS = 2
+
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
@@ -2254,6 +2263,10 @@ class BrushTool {
}
}
+ private clampSmoothingPrecision(value: number): number {
+ return Math.min(Math.max(value, 1), 100)
+ }
+
private drawWithBetterSmoothing(point: Point) {
// Add current point to the smoothing array
if (!this.smoothingCordsArray) {
@@ -2285,9 +2298,21 @@ class BrushTool {
totalLength += Math.sqrt(dx * dx + dy * dy)
}
- const distanceBetweenPoints =
- (this.brushSettings.size / this.brushSettings.smoothingPrecision) * 6
- const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
+ const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
+ const minSteps = BrushTool.SMOOTHING_MIN_STEPS
+
+ const smoothing = this.clampSmoothingPrecision(
+ this.brushSettings.smoothingPrecision
+ )
+ const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
+
+ // Optionality to use exponential curve
+ const stepNr = Math.round(
+ Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
+ )
+
+ // Calculate step distance capped by brush size
+ const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
@@ -2435,101 +2460,205 @@ class BrushTool {
const hardness = brushSettings.hardness
const x = point.x
const y = point.y
- // Extend the gradient radius beyond the brush size
- const extendedSize = size * (2 - hardness)
+ const brushRadius = size
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
const currentTool = await this.messageBroker.pull('currentTool')
- // handle paint pen
+ // Helper function to get or create cached brush texture
+ const getCachedBrushTexture = (
+ radius: number,
+ hardness: number,
+ color: string,
+ opacity: number
+ ): HTMLCanvasElement => {
+ const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
+
+ if (BrushTool.brushTextureCache.has(cacheKey)) {
+ return BrushTool.brushTextureCache.get(cacheKey)!
+ }
+
+ const tempCanvas = document.createElement('canvas')
+ const tempCtx = tempCanvas.getContext('2d')!
+ const size = radius * 2
+ tempCanvas.width = size
+ tempCanvas.height = size
+
+ const centerX = size / 2
+ const centerY = size / 2
+ const hardRadius = radius * hardness
+
+ const imageData = tempCtx.createImageData(size, size)
+ const data = imageData.data
+ const { r, g, b } = parseToRgb(color)
+
+ // Pre-calculate values to avoid repeated computations
+ const fadeRange = radius - hardRadius
+
+ for (let y = 0; y < size; y++) {
+ const dy = y - centerY
+ for (let x = 0; x < size; x++) {
+ const dx = x - centerX
+ const index = (y * size + x) * 4
+
+ // Calculate square distance (Chebyshev distance)
+ const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
+
+ let pixelOpacity = 0
+ if (distFromEdge <= hardRadius) {
+ pixelOpacity = opacity
+ } else if (distFromEdge <= radius) {
+ const fadeProgress = (distFromEdge - hardRadius) / fadeRange
+ pixelOpacity = opacity * (1 - fadeProgress)
+ }
+
+ data[index] = r
+ data[index + 1] = g
+ data[index + 2] = b
+ data[index + 3] = pixelOpacity * 255
+ }
+ }
+
+ tempCtx.putImageData(imageData, 0, 0)
+
+ // Cache the texture
+ BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
+
+ return tempCanvas
+ }
+
+ // RGB brush logic
if (
this.activeLayer === 'rgb' &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
- let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
- if (hardness === 1) {
- gradient.addColorStop(0, rgbaColor)
- gradient.addColorStop(
- 1,
- this.formatRgba(this.rgbColor, brushSettingsSliderOpacity)
+
+ if (brushType === BrushShape.Rect && hardness < 1) {
+ const brushTexture = getCachedBrushTexture(
+ brushRadius,
+ hardness,
+ rgbaColor,
+ opacity
)
- } else {
- gradient.addColorStop(0, rgbaColor)
- gradient.addColorStop(hardness, rgbaColor)
- gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
+ rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
+ return
+ }
+
+ // For max hardness, use solid fill to avoid anti-aliasing
+ if (hardness === 1) {
+ rgbCtx.fillStyle = rgbaColor
+ rgbCtx.beginPath()
+ if (brushType === BrushShape.Rect) {
+ rgbCtx.rect(
+ x - brushRadius,
+ y - brushRadius,
+ brushRadius * 2,
+ brushRadius * 2
+ )
+ } else {
+ rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
+ }
+ rgbCtx.fill()
+ return
}
+
+ // For soft brushes, use gradient
+ let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
+ gradient.addColorStop(0, rgbaColor)
+ gradient.addColorStop(
+ hardness,
+ this.formatRgba(this.rgbColor, opacity * 0.5)
+ )
+ gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
+
rgbCtx.fillStyle = gradient
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
- x - extendedSize,
- y - extendedSize,
- extendedSize * 2,
- extendedSize * 2
+ x - brushRadius,
+ y - brushRadius,
+ brushRadius * 2,
+ brushRadius * 2
)
} else {
- rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
+ rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
- let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
+ // Mask brush logic
+ if (brushType === BrushShape.Rect && hardness < 1) {
+ const baseColor = isErasing
+ ? `rgba(255, 255, 255, ${opacity})`
+ : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
+
+ const brushTexture = getCachedBrushTexture(
+ brushRadius,
+ hardness,
+ baseColor,
+ opacity
+ )
+ maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
+ return
+ }
+
+ // For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
+ const solidColor = isErasing
+ ? `rgba(255, 255, 255, ${opacity})`
+ : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
+
+ maskCtx.fillStyle = solidColor
+ maskCtx.beginPath()
+ if (brushType === BrushShape.Rect) {
+ maskCtx.rect(
+ x - brushRadius,
+ y - brushRadius,
+ brushRadius * 2,
+ brushRadius * 2
+ )
+ } else {
+ maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
+ }
+ maskCtx.fill()
+ return
+ }
+
+ // For soft brushes, use gradient
+ let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
+
+ if (isErasing) {
+ gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
+ gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
+ gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
+ } else {
gradient.addColorStop(
0,
- isErasing
- ? `rgba(255, 255, 255, ${opacity})`
- : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
+ `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
+ )
+ gradient.addColorStop(
+ hardness,
+ `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
)
gradient.addColorStop(
1,
- isErasing
- ? `rgba(255, 255, 255, ${opacity})`
- : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
+ `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
- } else {
- let softness = 1 - hardness
- let innerStop = Math.max(0, hardness - softness)
- let outerStop = size / extendedSize
-
- if (isErasing) {
- gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
- gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
- gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
- gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
- } else {
- gradient.addColorStop(
- 0,
- `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
- )
- gradient.addColorStop(
- innerStop,
- `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
- )
- gradient.addColorStop(
- outerStop,
- `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
- )
- gradient.addColorStop(
- 1,
- `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
- )
- }
}
maskCtx.fillStyle = gradient
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
- x - extendedSize,
- y - extendedSize,
- extendedSize * 2,
- extendedSize * 2
+ x - brushRadius,
+ y - brushRadius,
+ brushRadius * 2,
+ brushRadius * 2
)
} else {
- maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
+ maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
}
@@ -4185,30 +4314,35 @@ class UIManager {
const centerY = cursorPoint.y + pan_offset.y
const brush = this.brush
const hardness = brushSettings.hardness
- const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
+
+ // Now that brush size is constant, preview is simple
+ const brushRadius = brushSettings.size * zoom_ratio
+ const previewSize = brushRadius * 2
this.brushSizeSlider.value = String(brushSettings.size)
this.brushHardnessSlider.value = String(hardness)
- brush.style.width = extendedSize + 'px'
- brush.style.height = extendedSize + 'px'
- brush.style.left = centerX - extendedSize / 2 + 'px'
- brush.style.top = centerY - extendedSize / 2 + 'px'
+ brush.style.width = previewSize + 'px'
+ brush.style.height = previewSize + 'px'
+ brush.style.left = centerX - brushRadius + 'px'
+ brush.style.top = centerY - brushRadius + 'px'
if (hardness === 1) {
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
return
}
- const opacityStop = hardness / 4 + 0.25
+ // Simplified gradient - hardness controls where the fade starts
+ const midStop = hardness * 100
+ const outerStop = 100
this.brushPreviewGradient.style.background = `
- radial-gradient(
- circle,
- rgba(255, 0, 0, 0.5) 0%,
- rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
- rgba(255, 0, 0, 0) 100%
- )
+ radial-gradient(
+ circle,
+ rgba(255, 0, 0, 0.5) 0%,
+ rgba(255, 0, 0, 0.25) ${midStop}%,
+ rgba(255, 0, 0, 0) ${outerStop}%
+ )
`
}
diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts
index e432b7f256..fd7a99a2b4 100644
--- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts
+++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts
@@ -141,7 +141,8 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
*/
resolveInput(
slot: number,
- visited = new Set()
+ visited = new Set(),
+ type?: ISlotType
): ResolvedInput | undefined {
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[I]${slot}`
if (visited.has(uniqueId)) {
@@ -232,7 +233,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
`No output node DTO found for id [${outputNodeExecutionId}]`
)
- return outputNodeDto.resolveOutput(link.origin_slot, input.type, visited)
+ return outputNodeDto.resolveOutput(
+ link.origin_slot,
+ type ?? input.type,
+ visited
+ )
}
/**
@@ -284,7 +289,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Upstreamed: Other virtual nodes are bypassed using the same input/output index (slots must match)
if (node.isVirtualNode) {
- if (this.inputs.at(slot)) return this.resolveInput(slot, visited)
+ if (this.inputs.at(slot)) return this.resolveInput(slot, visited, type)
// Fallback check for nodes performing link redirection
const virtualLink = this.node.getInputLink(slot)
diff --git a/src/lib/litegraph/test/LinkConnector.integration.test.ts b/src/lib/litegraph/test/LinkConnector.integration.test.ts
index 5eb76aa783..ecbfc26ea9 100644
--- a/src/lib/litegraph/test/LinkConnector.integration.test.ts
+++ b/src/lib/litegraph/test/LinkConnector.integration.test.ts
@@ -334,12 +334,9 @@ describe('LinkConnector Integration', () => {
} = graph.getNodeById(nodeId)!
expect(input.link).toBeNull()
- // @ts-expect-error toBeOneOf not in type definitions
expect(output.links?.length).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
}
})
@@ -537,12 +534,9 @@ describe('LinkConnector Integration', () => {
} = graph.getNodeById(nodeId)!
expect(input.link).toBeNull()
- // @ts-expect-error toBeOneOf not in type definitions
expect(output.links?.length).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
}
})
@@ -856,12 +850,9 @@ describe('LinkConnector Integration', () => {
} = graph.getNodeById(nodeId)!
expect(input.link).toBeNull()
- // @ts-expect-error toBeOneOf not in type definitions
expect(output.links?.length).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
- // @ts-expect-error toBeOneOf not in type definitions
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
}
})
diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json
index 82f8d1c166..132f0c1ca4 100644
--- a/src/locales/ar/main.json
+++ b/src/locales/ar/main.json
@@ -27,6 +27,15 @@
"title": "مفتاح API",
"whitelistInfo": "حول المواقع غير المدرجة في القائمة البيضاء"
},
+ "deleteAccount": {
+ "cancel": "إلغاء",
+ "confirm": "حذف الحساب",
+ "confirmMessage": "هل أنت متأكد أنك تريد حذف حسابك؟ لا يمكن التراجع عن هذا الإجراء وسيتم حذف جميع بياناتك نهائيًا.",
+ "confirmTitle": "حذف الحساب",
+ "deleteAccount": "حذف الحساب",
+ "success": "تم حذف الحساب",
+ "successDetail": "تم حذف حسابك بنجاح."
+ },
"login": {
"andText": "و",
"confirmPasswordLabel": "تأكيد كلمة المرور",
@@ -542,37 +551,7 @@
"updateConsent": "لقد وافقت سابقًا على الإبلاغ عن الأعطال. نحن الآن نتتبع إحصائيات مبنية على الأحداث للمساعدة في تحديد الأخطاء وتحسين التطبيق. لا يتم جمع معلومات شخصية قابلة للتعريف."
},
"issueReport": {
- "contactFollowUp": "اتصل بي للمتابعة",
- "contactSupportDescription": "يرجى ملء النموذج أدناه مع تقريرك",
- "contactSupportTitle": "الاتصال بالدعم",
- "describeTheProblem": "صف المشكلة",
- "email": "البريد الإلكتروني",
- "feedbackTitle": "ساعدنا في تحسين ComfyUI من خلال تقديم الملاحظات",
- "helpFix": "المساعدة في الإصلاح",
- "helpTypes": {
- "billingPayments": "الفوترة / المدفوعات",
- "bugReport": "تقرير خطأ",
- "giveFeedback": "إرسال ملاحظات",
- "loginAccessIssues": "مشكلة في تسجيل الدخول / الوصول",
- "somethingElse": "أمر آخر"
- },
- "notifyResolve": "أعلمني عند الحل",
- "provideAdditionalDetails": "أضف تفاصيل إضافية",
- "provideEmail": "زودنا ببريدك الإلكتروني (اختياري)",
- "rating": "التقييم",
- "selectIssue": "اختر المشكلة",
- "stackTrace": "أثر التكديس",
- "submitErrorReport": "إرسال تقرير الخطأ (اختياري)",
- "systemStats": "إحصائيات النظام",
- "validation": {
- "descriptionRequired": "الوصف مطلوب",
- "helpTypeRequired": "نوع المساعدة مطلوب",
- "invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
- "maxLength": "الرسالة طويلة جداً",
- "selectIssueType": "يرجى اختيار نوع المشكلة"
- },
- "whatCanWeInclude": "حدد ما يجب تضمينه في التقرير",
- "whatDoYouNeedHelpWith": "بماذا تحتاج المساعدة؟"
+ "helpFix": "المساعدة في الإصلاح"
},
"load3d": {
"applyingTexture": "جارٍ تطبيق الخامة...",
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 50b6d8c3e6..9c249ae8de 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -210,37 +210,7 @@
}
},
"issueReport": {
- "submitErrorReport": "Submit Error Report (Optional)",
- "provideEmail": "Give us your email (optional)",
- "provideAdditionalDetails": "Provide additional details",
- "stackTrace": "Stack Trace",
- "systemStats": "System Stats",
- "contactFollowUp": "Contact me for follow up",
- "notifyResolve": "Notify me when resolved",
- "helpFix": "Help Fix This",
- "rating": "Rating",
- "feedbackTitle": "Help us improve ComfyUI by providing feedback",
- "contactSupportTitle": "Contact Support",
- "contactSupportDescription": "Please fill in the form below with your report",
- "selectIssue": "Select the issue",
- "whatDoYouNeedHelpWith": "What do you need help with?",
- "whatCanWeInclude": "Specify what to include in the report",
- "describeTheProblem": "Describe the problem",
- "email": "Email",
- "helpTypes": {
- "billingPayments": "Billing / Payments",
- "loginAccessIssues": "Login / Access Issues",
- "giveFeedback": "Give Feedback",
- "bugReport": "Bug Report",
- "somethingElse": "Something Else"
- },
- "validation": {
- "maxLength": "Message too long",
- "invalidEmail": "Please enter a valid email address",
- "selectIssueType": "Please select an issue type",
- "descriptionRequired": "Description is required",
- "helpTypeRequired": "Help type is required"
- }
+ "helpFix": "Help Fix This"
},
"color": {
"noColor": "No Color",
@@ -1601,6 +1571,15 @@
"passwordUpdate": {
"success": "Password Updated",
"successDetail": "Your password has been updated successfully"
+ },
+ "deleteAccount": {
+ "deleteAccount": "Delete Account",
+ "confirmTitle": "Delete Account",
+ "confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.",
+ "confirm": "Delete Account",
+ "cancel": "Cancel",
+ "success": "Account Deleted",
+ "successDetail": "Your account has been successfully deleted."
}
},
"validation": {
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index a3ba819462..47edc05773 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -27,6 +27,15 @@
"title": "Clave API",
"whitelistInfo": "Acerca de los sitios no incluidos en la lista blanca"
},
+ "deleteAccount": {
+ "cancel": "Cancelar",
+ "confirm": "Eliminar cuenta",
+ "confirmMessage": "¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer y eliminará permanentemente todos tus datos.",
+ "confirmTitle": "Eliminar cuenta",
+ "deleteAccount": "Eliminar cuenta",
+ "success": "Cuenta eliminada",
+ "successDetail": "Tu cuenta ha sido eliminada exitosamente."
+ },
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
@@ -542,37 +551,7 @@
"updateConsent": "Anteriormente optaste por reportar fallos. Ahora estamos rastreando métricas basadas en eventos para ayudar a identificar errores y mejorar la aplicación. No se recoge ninguna información personal identificable."
},
"issueReport": {
- "contactFollowUp": "Contáctame para seguimiento",
- "contactSupportDescription": "Por favor, complete el siguiente formulario con su reporte",
- "contactSupportTitle": "Contactar Soporte",
- "describeTheProblem": "Describa el problema",
- "email": "Correo electrónico",
- "feedbackTitle": "Ayúdanos a mejorar ComfyUI proporcionando comentarios",
- "helpFix": "Ayuda a Solucionar Esto",
- "helpTypes": {
- "billingPayments": "Facturación / Pagos",
- "bugReport": "Reporte de error",
- "giveFeedback": "Enviar comentarios",
- "loginAccessIssues": "Problemas de inicio de sesión / acceso",
- "somethingElse": "Otro"
- },
- "notifyResolve": "Notifícame cuando se resuelva",
- "provideAdditionalDetails": "Proporciona detalles adicionales (opcional)",
- "provideEmail": "Danos tu correo electrónico (opcional)",
- "rating": "Calificación",
- "selectIssue": "Seleccione el problema",
- "stackTrace": "Rastreo de Pila",
- "submitErrorReport": "Enviar Reporte de Error (Opcional)",
- "systemStats": "Estadísticas del Sistema",
- "validation": {
- "descriptionRequired": "Se requiere una descripción",
- "helpTypeRequired": "Se requiere el tipo de ayuda",
- "invalidEmail": "Por favor ingresa una dirección de correo electrónico válida",
- "maxLength": "Mensaje demasiado largo",
- "selectIssueType": "Por favor, seleccione un tipo de problema"
- },
- "whatCanWeInclude": "Especifique qué incluir en el reporte",
- "whatDoYouNeedHelpWith": "¿Con qué necesita ayuda?"
+ "helpFix": "Ayuda a Solucionar Esto"
},
"load3d": {
"applyingTexture": "Aplicando textura...",
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index cc13f3ac97..b6d5df6a87 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -27,6 +27,15 @@
"title": "Clé API",
"whitelistInfo": "À propos des sites non autorisés"
},
+ "deleteAccount": {
+ "cancel": "Annuler",
+ "confirm": "Supprimer le compte",
+ "confirmMessage": "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible et supprimera définitivement toutes vos données.",
+ "confirmTitle": "Supprimer le compte",
+ "deleteAccount": "Supprimer le compte",
+ "success": "Compte supprimé",
+ "successDetail": "Votre compte a été supprimé avec succès."
+ },
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
@@ -542,37 +551,7 @@
"updateConsent": "Vous avez précédemment accepté de signaler les plantages. Nous suivons maintenant des métriques basées sur les événements pour aider à identifier les bugs et améliorer l'application. Aucune information personnelle identifiable n'est collectée."
},
"issueReport": {
- "contactFollowUp": "Contactez-moi pour un suivi",
- "contactSupportDescription": "Veuillez remplir le formulaire ci-dessous avec votre signalement",
- "contactSupportTitle": "Contacter le support",
- "describeTheProblem": "Décrivez le problème",
- "email": "E-mail",
- "feedbackTitle": "Aidez-nous à améliorer ComfyUI en fournissant des commentaires",
- "helpFix": "Aidez à résoudre cela",
- "helpTypes": {
- "billingPayments": "Facturation / Paiements",
- "bugReport": "Signaler un bug",
- "giveFeedback": "Donner un avis",
- "loginAccessIssues": "Problèmes de connexion / d'accès",
- "somethingElse": "Autre chose"
- },
- "notifyResolve": "Prévenez-moi lorsque résolu",
- "provideAdditionalDetails": "Fournir des détails supplémentaires (facultatif)",
- "provideEmail": "Donnez-nous votre email (Facultatif)",
- "rating": "Évaluation",
- "selectIssue": "Sélectionnez le problème",
- "stackTrace": "Trace de la pile",
- "submitErrorReport": "Soumettre un rapport d'erreur (Facultatif)",
- "systemStats": "Statistiques du système",
- "validation": {
- "descriptionRequired": "La description est requise",
- "helpTypeRequired": "Le type d'aide est requis",
- "invalidEmail": "Veuillez entrer une adresse e-mail valide",
- "maxLength": "Message trop long",
- "selectIssueType": "Veuillez sélectionner un type de problème"
- },
- "whatCanWeInclude": "Précisez ce qu'il faut inclure dans le rapport",
- "whatDoYouNeedHelpWith": "Avec quoi avez-vous besoin d'aide ?"
+ "helpFix": "Aidez à résoudre cela"
},
"load3d": {
"applyingTexture": "Application de la texture...",
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 9b3838f695..c513b5e26d 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -27,6 +27,15 @@
"title": "APIキー",
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
},
+ "deleteAccount": {
+ "cancel": "キャンセル",
+ "confirm": "アカウントを削除",
+ "confirmMessage": "本当にアカウントを削除しますか?この操作は元に戻せず、すべてのデータが完全に削除されます。",
+ "confirmTitle": "アカウントを削除",
+ "deleteAccount": "アカウントを削除",
+ "success": "アカウントが削除されました",
+ "successDetail": "アカウントは正常に削除されました。"
+ },
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
@@ -542,37 +551,7 @@
"updateConsent": "以前、クラッシュレポートを報告することに同意していました。現在、バグの特定とアプリの改善を助けるためにイベントベースのメトリクスを追跡しています。個人を特定できる情報は収集されません。"
},
"issueReport": {
- "contactFollowUp": "フォローアップのために私に連絡する",
- "contactSupportDescription": "下記のフォームにご報告内容をご記入ください",
- "contactSupportTitle": "サポートに連絡",
- "describeTheProblem": "問題の内容を記述してください",
- "email": "メールアドレス",
- "feedbackTitle": "フィードバックを提供してComfyUIの改善にご協力ください",
- "helpFix": "これを修正するのを助ける",
- "helpTypes": {
- "billingPayments": "請求/支払い",
- "bugReport": "バグ報告",
- "giveFeedback": "フィードバックを送る",
- "loginAccessIssues": "ログイン/アクセスの問題",
- "somethingElse": "その他"
- },
- "notifyResolve": "解決したときに通知する",
- "provideAdditionalDetails": "追加の詳細を提供する(オプション)",
- "provideEmail": "あなたのメールアドレスを教えてください(オプション)",
- "rating": "評価",
- "selectIssue": "問題を選択してください",
- "stackTrace": "スタックトレース",
- "submitErrorReport": "エラーレポートを提出する(オプション)",
- "systemStats": "システム統計",
- "validation": {
- "descriptionRequired": "説明は必須です",
- "helpTypeRequired": "ヘルプの種類は必須です",
- "invalidEmail": "有効なメールアドレスを入力してください",
- "maxLength": "メッセージが長すぎます",
- "selectIssueType": "問題の種類を選択してください"
- },
- "whatCanWeInclude": "レポートに含める内容を指定してください",
- "whatDoYouNeedHelpWith": "どのようなサポートが必要ですか?"
+ "helpFix": "これを修正するのを助ける"
},
"load3d": {
"applyingTexture": "テクスチャを適用中...",
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index b9e5086bda..bff6da7b2c 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -27,6 +27,15 @@
"title": "API 키",
"whitelistInfo": "비허용 사이트에 대하여"
},
+ "deleteAccount": {
+ "cancel": "취소",
+ "confirm": "계정 삭제",
+ "confirmMessage": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며 모든 데이터가 영구적으로 삭제됩니다.",
+ "confirmTitle": "계정 삭제",
+ "deleteAccount": "계정 삭제",
+ "success": "계정이 삭제되었습니다",
+ "successDetail": "계정이 성공적으로 삭제되었습니다."
+ },
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
@@ -542,37 +551,7 @@
"updateConsent": "이전에 충돌 보고에 동의하셨습니다. 이제 버그를 식별하고 앱을 개선하기 위해 이벤트 기반 통계 정보의 추적을 시작합니다. 개인을 식별할 수 있는 정보는 수집되지 않습니다."
},
"issueReport": {
- "contactFollowUp": "추적 조사를 위해 연락해 주세요",
- "contactSupportDescription": "아래 양식에 보고 내용을 작성해 주세요",
- "contactSupportTitle": "지원팀에 문의하기",
- "describeTheProblem": "문제를 설명해 주세요",
- "email": "이메일",
- "feedbackTitle": "피드백을 제공함으로써 ComfyUI를 개선하는 데 도움을 주십시오",
- "helpFix": "이 문제 해결에 도움을 주세요",
- "helpTypes": {
- "billingPayments": "결제 / 지불",
- "bugReport": "버그 신고",
- "giveFeedback": "피드백 제공",
- "loginAccessIssues": "로그인 / 접근 문제",
- "somethingElse": "기타"
- },
- "notifyResolve": "해결되었을 때 알려주세요",
- "provideAdditionalDetails": "추가 세부 사항 제공 (선택 사항)",
- "provideEmail": "이메일을 알려주세요 (선택 사항)",
- "rating": "평가",
- "selectIssue": "문제를 선택하세요",
- "stackTrace": "스택 추적",
- "submitErrorReport": "오류 보고서 제출 (선택 사항)",
- "systemStats": "시스템 통계",
- "validation": {
- "descriptionRequired": "설명은 필수입니다",
- "helpTypeRequired": "도움 유형은 필수입니다",
- "invalidEmail": "유효한 이메일 주소를 입력해 주세요",
- "maxLength": "메시지가 너무 깁니다",
- "selectIssueType": "문제 유형을 선택해 주세요"
- },
- "whatCanWeInclude": "보고서에 포함할 내용을 지정하세요",
- "whatDoYouNeedHelpWith": "어떤 도움이 필요하신가요?"
+ "helpFix": "이 문제 해결에 도움을 주세요"
},
"load3d": {
"applyingTexture": "텍스처 적용 중...",
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index a6da070ae3..fada37527e 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -27,6 +27,15 @@
"title": "API-ключ",
"whitelistInfo": "О не включённых в белый список сайтах"
},
+ "deleteAccount": {
+ "cancel": "Отмена",
+ "confirm": "Удалить аккаунт",
+ "confirmMessage": "Вы уверены, что хотите удалить свой аккаунт? Это действие необратимо и приведёт к безвозвратному удалению всех ваших данных.",
+ "confirmTitle": "Удалить аккаунт",
+ "deleteAccount": "Удалить аккаунт",
+ "success": "Аккаунт удалён",
+ "successDetail": "Ваш аккаунт был успешно удалён."
+ },
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
@@ -542,37 +551,7 @@
"updateConsent": "Вы ранее согласились на отчётность об ошибках. Теперь мы отслеживаем метрики событий, чтобы помочь выявить ошибки и улучшить приложение. Личная идентифицируемая информация не собирается."
},
"issueReport": {
- "contactFollowUp": "Свяжитесь со мной для уточнения",
- "contactSupportDescription": "Пожалуйста, заполните форму ниже для отправки вашего отчёта",
- "contactSupportTitle": "Связаться с поддержкой",
- "describeTheProblem": "Опишите проблему",
- "email": "Электронная почта",
- "feedbackTitle": "Помогите нам улучшить ComfyUI, оставив отзыв",
- "helpFix": "Помочь исправить это",
- "helpTypes": {
- "billingPayments": "Оплата / Платежи",
- "bugReport": "Сообщить об ошибке",
- "giveFeedback": "Оставить отзыв",
- "loginAccessIssues": "Проблемы со входом / доступом",
- "somethingElse": "Другое"
- },
- "notifyResolve": "Уведомить меня, когда проблема будет решена",
- "provideAdditionalDetails": "Предоставьте дополнительные сведения (необязательно)",
- "provideEmail": "Укажите вашу электронную почту (необязательно)",
- "rating": "Рейтинг",
- "selectIssue": "Выберите проблему",
- "stackTrace": "Трассировка стека",
- "submitErrorReport": "Отправить отчёт об ошибке (необязательно)",
- "systemStats": "Статистика системы",
- "validation": {
- "descriptionRequired": "Описание обязательно",
- "helpTypeRequired": "Тип помощи обязателен",
- "invalidEmail": "Пожалуйста, введите действительный адрес электронной почты",
- "maxLength": "Сообщение слишком длинное",
- "selectIssueType": "Пожалуйста, выберите тип проблемы"
- },
- "whatCanWeInclude": "Уточните, что включить в отчёт",
- "whatDoYouNeedHelpWith": "С чем вам нужна помощь?"
+ "helpFix": "Помочь исправить это"
},
"load3d": {
"applyingTexture": "Применение текстуры...",
diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json
index f1ec65bb19..75638a6f23 100644
--- a/src/locales/zh-TW/main.json
+++ b/src/locales/zh-TW/main.json
@@ -27,6 +27,15 @@
"title": "API 金鑰",
"whitelistInfo": "關於未列入白名單的網站"
},
+ "deleteAccount": {
+ "cancel": "取消",
+ "confirm": "刪除帳號",
+ "confirmMessage": "您確定要刪除您的帳號嗎?此操作無法復原,且將永久移除您所有的資料。",
+ "confirmTitle": "刪除帳號",
+ "deleteAccount": "刪除帳號",
+ "success": "帳號已刪除",
+ "successDetail": "您的帳號已成功刪除。"
+ },
"login": {
"andText": "以及",
"confirmPasswordLabel": "確認密碼",
@@ -542,37 +551,7 @@
"updateConsent": "您先前已選擇回報當機。現在我們會追蹤事件型統計資料,以協助找出錯誤並改進應用程式。不會收集任何可識別個人身分的資訊。"
},
"issueReport": {
- "contactFollowUp": "需要聯絡我以便後續追蹤",
- "contactSupportDescription": "請填寫下列表單並提交您的報告",
- "contactSupportTitle": "聯絡客服支援",
- "describeTheProblem": "請描述問題",
- "email": "電子郵件",
- "feedbackTitle": "協助我們改進 ComfyUI,請提供您的回饋",
- "helpFix": "協助修復此問題",
- "helpTypes": {
- "billingPayments": "帳單/付款問題",
- "bugReport": "錯誤回報",
- "giveFeedback": "提供回饋",
- "loginAccessIssues": "登入/存取問題",
- "somethingElse": "其他"
- },
- "notifyResolve": "問題解決時通知我",
- "provideAdditionalDetails": "提供更多細節",
- "provideEmail": "請提供您的電子郵件(選填)",
- "rating": "評分",
- "selectIssue": "請選擇問題",
- "stackTrace": "堆疊追蹤",
- "submitErrorReport": "提交錯誤報告(選填)",
- "systemStats": "系統狀態",
- "validation": {
- "descriptionRequired": "請填寫問題描述",
- "helpTypeRequired": "請選擇協助類型",
- "invalidEmail": "請輸入有效的電子郵件地址",
- "maxLength": "訊息過長",
- "selectIssueType": "請選擇問題類型"
- },
- "whatCanWeInclude": "請說明報告中要包含哪些內容",
- "whatDoYouNeedHelpWith": "您需要什麼協助?"
+ "helpFix": "協助修復此問題"
},
"load3d": {
"applyingTexture": "正在套用材質貼圖...",
diff --git a/src/locales/zh/commands.json b/src/locales/zh/commands.json
index 81bac50adc..a17f1d0a77 100644
--- a/src/locales/zh/commands.json
+++ b/src/locales/zh/commands.json
@@ -48,7 +48,7 @@
"label": "适应视图到选中节点"
},
"Comfy_Canvas_Lock": {
- "label": "鎖定畫布"
+ "label": "锁定画布"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
@@ -93,7 +93,7 @@
"label": "固定/取消固定选中项"
},
"Comfy_Canvas_Unlock": {
- "label": "解鎖畫布"
+ "label": "解锁画布"
},
"Comfy_Canvas_ZoomIn": {
"label": "放大"
@@ -111,7 +111,7 @@
"label": "联系支持"
},
"Comfy_Dev_ShowModelSelector": {
- "label": "顯示模型選擇器(開發)"
+ "label": "显示模型选择器(开发)"
},
"Comfy_DuplicateWorkflow": {
"label": "复制当前工作流"
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index eef7782898..d98dbc008e 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -27,6 +27,15 @@
"title": "API 密钥",
"whitelistInfo": "关于非白名单网站"
},
+ "deleteAccount": {
+ "cancel": "取消",
+ "confirm": "删除账户",
+ "confirmMessage": "您确定要删除您的账户吗?此操作无法撤销,并且会永久删除您的所有数据。",
+ "confirmTitle": "删除账户",
+ "deleteAccount": "删除账户",
+ "success": "账户已删除",
+ "successDetail": "您的账户已成功删除。"
+ },
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
@@ -313,7 +322,7 @@
"feedback": "反馈",
"filter": "过滤",
"findIssues": "查找问题",
- "frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
+ "frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
@@ -410,17 +419,17 @@
},
"graphCanvasMenu": {
"fitView": "适应视图",
- "focusMode": "專注模式",
- "hand": "拖曳",
- "hideLinks": "隱藏連結",
+ "focusMode": "专注模式",
+ "hand": "拖拽",
+ "hideLinks": "隐藏链接",
"panMode": "平移模式",
"resetView": "重置视图",
- "select": "選取",
+ "select": "选择",
"selectMode": "选择模式",
- "showLinks": "顯示連結",
+ "showLinks": "显示链接",
"toggleMinimap": "切换小地图",
"zoomIn": "放大",
- "zoomOptions": "縮放選項",
+ "zoomOptions": "缩放选项",
"zoomOut": "缩小"
},
"groupNode": {
@@ -542,37 +551,7 @@
"updateConsent": "您之前选择了报告崩溃。我们现在正在跟踪基于事件的度量,以帮助识别错误并改进应用程序。我们不收集任何个人可识别信息。"
},
"issueReport": {
- "contactFollowUp": "跟进联系我",
- "contactSupportDescription": "请填写下方表格提交您的报告",
- "contactSupportTitle": "联系支持",
- "describeTheProblem": "描述问题",
- "email": "电子邮箱",
- "feedbackTitle": "通过提供反馈帮助我们改进ComfyUI",
- "helpFix": "帮助修复这个",
- "helpTypes": {
- "billingPayments": "账单 / 支付",
- "bugReport": "错误报告",
- "giveFeedback": "提交反馈",
- "loginAccessIssues": "登录 / 访问问题",
- "somethingElse": "其他"
- },
- "notifyResolve": "解决时通知我",
- "provideAdditionalDetails": "提供额外的详细信息(可选)",
- "provideEmail": "提供您的电子邮件(可选)",
- "rating": "评分",
- "selectIssue": "选择问题",
- "stackTrace": "堆栈跟踪",
- "submitErrorReport": "提交错误报告(可选)",
- "systemStats": "系统状态",
- "validation": {
- "descriptionRequired": "描述为必填项",
- "helpTypeRequired": "帮助类型为必选项",
- "invalidEmail": "请输入有效的电子邮件地址",
- "maxLength": "消息过长",
- "selectIssueType": "请选择一个问题类型"
- },
- "whatCanWeInclude": "请说明报告中需要包含的内容",
- "whatDoYouNeedHelpWith": "您需要什么帮助?"
+ "helpFix": "帮助修复这个"
},
"load3d": {
"applyingTexture": "应用纹理中...",
@@ -806,7 +785,7 @@
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大笔刷大小",
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
- "Lock Canvas": "鎖定畫布",
+ "Lock Canvas": "锁定画布",
"Manage group nodes": "管理组节点",
"Manager": "管理器",
"Minimap": "小地图",
@@ -847,8 +826,8 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
- "Show Keybindings Dialog": "顯示快捷鍵對話框",
- "Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
+ "Show Keybindings Dialog": "显示快捷键对话框",
+ "Show Model Selector (Dev)": "显示模型选择器(开发用)",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Essential Bottom Panel": "切换基础底部面板",
@@ -861,7 +840,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
- "Unlock Canvas": "解除鎖定畫布",
+ "Unlock Canvas": "解除锁定画布",
"Unpack the selected Subgraph": "解包选中子图",
"Workflows": "工作流",
"Zoom In": "放大画面",
@@ -1684,8 +1663,8 @@
},
"versionMismatchWarning": {
"dismiss": "关闭",
- "frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
- "frontendOutdated": "前端版本 {frontendVersion} 已过时。後端需要 {requiredVersion} 版或更高版本。",
+ "frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
+ "frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
@@ -1703,9 +1682,9 @@
"saveWorkflow": "保存工作流"
},
"zoomControls": {
- "hideMinimap": "隱藏小地圖",
- "label": "縮放控制",
- "showMinimap": "顯示小地圖",
- "zoomToFit": "適合畫面"
+ "hideMinimap": "隐藏小地图",
+ "label": "缩放控制",
+ "showMinimap": "显示小地图",
+ "zoomToFit": "适合画面"
}
}
\ No newline at end of file
diff --git a/src/schemas/issueReportSchema.ts b/src/schemas/issueReportSchema.ts
deleted file mode 100644
index d85f75cc58..0000000000
--- a/src/schemas/issueReportSchema.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { z } from 'zod'
-
-import { t } from '@/i18n'
-
-const checkboxField = z.boolean().optional()
-export const issueReportSchema = z
- .object({
- contactInfo: z.string().email().max(320).optional().or(z.literal('')),
- details: z
- .string()
- .min(1, { message: t('validation.descriptionRequired') })
- .max(5_000, { message: t('validation.maxLength', { length: 5_000 }) })
- .optional(),
- helpType: z.string().optional()
- })
- .catchall(checkboxField)
- .refine((data) => Object.values(data).some((value) => value), {
- path: ['details', 'helpType']
- })
- .refine((data) => data.helpType !== undefined && data.helpType !== '', {
- message: t('issueReport.validation.helpTypeRequired'),
- path: ['helpType']
- })
- .refine((data) => data.details !== undefined && data.details !== '', {
- message: t('issueReport.validation.descriptionRequired'),
- path: ['details']
- })
-export type IssueReportFormData = z.infer
diff --git a/src/services/README.md b/src/services/README.md
index afefba70df..12226587ab 100644
--- a/src/services/README.md
+++ b/src/services/README.md
@@ -12,7 +12,7 @@ This directory contains the service layer for the ComfyUI frontend application.
## Overview
-Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
+Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
@@ -57,21 +57,25 @@ While services can interact with both UI components and stores (centralized stat
## Core Services
-The following table lists ALL services in the system as of 2025-01-30:
+The following table lists ALL services in the system as of 2025-09-01:
### Main Services
| Service | Description | Category |
|---------|-------------|----------|
+| audioService.ts | Manages audio recording and WAV encoding functionality | Media |
| autoQueueService.ts | Manages automatic queue execution | Execution |
| colorPaletteService.ts | Handles color palette management and customization | UI |
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
+| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
| dialogService.ts | Provides dialog and modal management | UI |
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
| load3dService.ts | Manages 3D model loading and visualization | 3D |
+| mediaCacheService.ts | Manages media file caching with blob storage and cleanup | Media |
+| newUserService.ts | Handles new user initialization and onboarding | System |
| nodeHelpService.ts | Provides node documentation and help | Nodes |
| nodeOrganizationService.ts | Handles node organization and categorization | Nodes |
| nodeSearchService.ts | Implements node search functionality | Search |
@@ -105,47 +109,82 @@ For complex services with state management and multiple methods, class-based ser
```typescript
export class NodeSearchService {
// Service state
- private readonly nodeFuseSearch: FuseSearch
- private readonly filters: Record>
+ public readonly nodeFuseSearch: FuseSearch
+ public readonly inputTypeFilter: FuseFilter
+ public readonly outputTypeFilter: FuseFilter
+ public readonly nodeCategoryFilter: FuseFilter
+ public readonly nodeSourceFilter: FuseFilter
constructor(data: ComfyNodeDefImpl[]) {
- // Initialize state
- this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
-
- // Setup filters
- this.filters = {
- inputType: new FuseFilter(/* options */),
- category: new FuseFilter(/* options */)
- }
+ // Initialize search index
+ this.nodeFuseSearch = new FuseSearch(data, {
+ fuseOptions: {
+ keys: ['name', 'display_name'],
+ includeScore: true,
+ threshold: 0.3,
+ shouldSort: false,
+ useExtendedSearch: true
+ },
+ createIndex: true,
+ advancedScoring: true
+ })
+
+ // Setup individual filters
+ const fuseOptions = { includeScore: true, threshold: 0.3, shouldSort: true }
+ this.inputTypeFilter = new FuseFilter(data, {
+ id: 'input',
+ name: 'Input Type',
+ invokeSequence: 'i',
+ getItemOptions: (node) => Object.values(node.inputs).map((input) => input.type),
+ fuseOptions
+ })
+ // Additional filters initialized similarly...
}
- public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
- // Implementation
- return results
+ public searchNode(
+ query: string,
+ filters: FuseFilterWithValue[] = []
+ ): ComfyNodeDefImpl[] {
+ const matchedNodes = this.nodeFuseSearch.search(query)
+ return matchedNodes.filter((node) => {
+ return filters.every((filterAndValue) => {
+ const { filterDef, value } = filterAndValue
+ return filterDef.matches(node, value, { wildcard: '*' })
+ })
+ })
+ }
+
+ get nodeFilters(): FuseFilter[] {
+ return [
+ this.inputTypeFilter,
+ this.outputTypeFilter,
+ this.nodeCategoryFilter,
+ this.nodeSourceFilter
+ ]
}
}
```
### 2. Composable-style Services
-For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
+For services that need to integrate with Vue's reactivity system or handle API interactions, we use composable-style services:
```typescript
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
// State (reactive if needed)
const data = ref(initialData)
-
+
// Search functionality
function searchNodes(query: string) {
// Implementation
return results
}
-
+
// Additional methods
function refreshData(newData: ComfyNodeDefImpl[]) {
data.value = newData
}
-
+
// Return public API
return {
searchNodes,
@@ -154,12 +193,35 @@ export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
}
```
-When deciding between these approaches, consider:
+### Service Pattern Comparison
-1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
-2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
-3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
-4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
+| Aspect | Class-Based Services | Composable-Style Services | Bootstrap Services | Shared State Services |
+|--------|---------------------|---------------------------|-------------------|---------------------|
+| **Count** | 4 services | 18+ services | 1 service | 1 service |
+| **Export Pattern** | `export class ServiceName` | `export function useServiceName()` | `export function setupX()` | `export function serviceFactory()` |
+| **Instantiation** | `new ServiceName(data)` | `useServiceName()` | Direct function call | Direct function call |
+| **Best For** | Complex data structures, search algorithms, expensive initialization | Vue integration, API calls, reactive state | One-time app initialization | Singleton-like shared state |
+| **State Management** | Encapsulated private/public properties | External stores + reactive refs | Event listeners, side effects | Module-level state |
+| **Vue Integration** | Manual integration needed | Native reactivity support | N/A | Varies |
+| **Examples** | `NodeSearchService`, `Load3dService` | `workflowService`, `dialogService` | `autoQueueService` | `newUserService` |
+
+### Decision Criteria
+
+When choosing between these approaches, consider:
+
+1. **Data Structure Complexity**: Classes work well for services managing multiple related data structures (search indices, filters, complex state)
+2. **Initialization Cost**: Classes are ideal when expensive setup should happen once and be controlled by instantiation
+3. **Vue Integration**: Composables integrate seamlessly with Vue's reactivity system and stores
+4. **API Interactions**: Composables handle async operations and API calls more naturally
+5. **State Management**: Classes provide strong encapsulation; composables work better with external state management
+6. **Application Bootstrap**: Bootstrap services handle one-time app initialization, event listener setup, and side effects
+7. **Singleton Behavior**: Shared state services provide module-level state that persists across multiple function calls
+
+**Current Usage Patterns:**
+- **Class-based services (4)**: Complex data processing, search algorithms, expensive initialization
+- **Composable-style services (18+)**: UI interactions, API calls, store integration, reactive state management
+- **Bootstrap services (1)**: One-time application initialization and event handler setup
+- **Shared state services (1)**: Singleton-like behavior with module-level state management
### Service Template
@@ -172,7 +234,7 @@ Here's a template for creating a new composable-style service:
export function useExampleService() {
// Private state/functionality
const cache = new Map()
-
+
/**
* Description of what this method does
* @param param1 Description of parameter
@@ -188,7 +250,7 @@ export function useExampleService() {
throw error
}
}
-
+
// Return public API
return {
performOperation
@@ -206,16 +268,16 @@ Services in ComfyUI frequently use the following design patterns:
export function useCachedService() {
const cache = new Map()
const pendingRequests = new Map()
-
+
async function fetchData(key: string) {
// Check cache first
if (cache.has(key)) return cache.get(key)
-
+
// Check if request is already in progress
if (pendingRequests.has(key)) {
return pendingRequests.get(key)
}
-
+
// Perform new request
const requestPromise = fetch(`/api/${key}`)
.then(response => response.json())
@@ -224,11 +286,11 @@ export function useCachedService() {
pendingRequests.delete(key)
return data
})
-
+
pendingRequests.set(key, requestPromise)
return requestPromise
}
-
+
return { fetchData }
}
```
@@ -248,7 +310,7 @@ export function useNodeFactory() {
throw new Error(`Unknown node type: ${type}`)
}
}
-
+
return { createNode }
}
```
@@ -267,11 +329,243 @@ export function useWorkflowService(
const storagePath = await storageService.getPath(name)
return apiService.saveData(storagePath, graphData)
}
-
+
return { saveWorkflow }
}
```
+## Testing Services
+
+Services in ComfyUI can be tested effectively using different approaches depending on their implementation pattern.
+
+### Testing Class-Based Services
+
+**Setup Requirements:**
+```typescript
+// Manual instantiation required
+const mockData = [/* test data */]
+const service = new NodeSearchService(mockData)
+```
+
+**Characteristics:**
+- Requires constructor argument preparation
+- State is encapsulated within the class instance
+- Direct method calls on the instance
+- Good isolation - each test gets a fresh instance
+
+**Example:**
+```typescript
+describe('NodeSearchService', () => {
+ let service: NodeSearchService
+
+ beforeEach(() => {
+ const mockNodes = [/* mock node definitions */]
+ service = new NodeSearchService(mockNodes)
+ })
+
+ test('should search nodes by query', () => {
+ const results = service.searchNode('test query')
+ expect(results).toHaveLength(2)
+ })
+
+ test('should apply filters correctly', () => {
+ const filters = [{ filterDef: service.inputTypeFilter, value: 'IMAGE' }]
+ const results = service.searchNode('*', filters)
+ expect(results.every(node => /* has IMAGE input */)).toBe(true)
+ })
+})
+```
+
+### Testing Composable-Style Services
+
+**Setup Requirements:**
+```typescript
+// Direct function call, no instantiation
+const { saveWorkflow, loadWorkflow } = useWorkflowService()
+```
+
+**Characteristics:**
+- No instantiation needed
+- Integrates naturally with Vue Test Utils
+- Easy mocking of reactive dependencies
+- External store dependencies need mocking
+
+**Example:**
+```typescript
+describe('useWorkflowService', () => {
+ beforeEach(() => {
+ // Mock external dependencies
+ vi.mock('@/stores/settingStore', () => ({
+ useSettingStore: () => ({
+ get: vi.fn().mockReturnValue(true),
+ set: vi.fn()
+ })
+ }))
+
+ vi.mock('@/stores/toastStore', () => ({
+ useToastStore: () => ({
+ add: vi.fn()
+ })
+ }))
+ })
+
+ test('should save workflow with prompt', async () => {
+ const { saveWorkflow } = useWorkflowService()
+ await saveWorkflow('test-workflow')
+
+ // Verify interactions with mocked dependencies
+ expect(mockSettingStore.get).toHaveBeenCalledWith('Comfy.PromptFilename')
+ })
+})
+```
+
+### Testing Bootstrap Services
+
+**Focus on Setup Behavior:**
+```typescript
+describe('autoQueueService', () => {
+ beforeEach(() => {
+ // Mock global dependencies
+ vi.mock('@/scripts/api', () => ({
+ api: {
+ addEventListener: vi.fn()
+ }
+ }))
+
+ vi.mock('@/scripts/app', () => ({
+ app: {
+ queuePrompt: vi.fn()
+ }
+ }))
+ })
+
+ test('should setup event listeners', () => {
+ setupAutoQueueHandler()
+
+ expect(mockApi.addEventListener).toHaveBeenCalledWith('graphChanged', expect.any(Function))
+ })
+
+ test('should handle graph changes when auto-queue enabled', () => {
+ setupAutoQueueHandler()
+
+ // Simulate graph change event
+ const graphChangeHandler = mockApi.addEventListener.mock.calls[0][1]
+ graphChangeHandler()
+
+ expect(mockApp.queuePrompt).toHaveBeenCalled()
+ })
+})
+```
+
+### Testing Shared State Services
+
+**Focus on Shared State Behavior:**
+```typescript
+describe('newUserService', () => {
+ beforeEach(() => {
+ // Reset module state between tests
+ vi.resetModules()
+ })
+
+ test('should return consistent API across calls', () => {
+ const service1 = newUserService()
+ const service2 = newUserService()
+
+ // Same functions returned (shared behavior)
+ expect(service1.isNewUser).toBeDefined()
+ expect(service2.isNewUser).toBeDefined()
+ })
+
+ test('should share state between service instances', async () => {
+ const service1 = newUserService()
+ const service2 = newUserService()
+
+ // Initialize through one instance
+ const mockSettingStore = { set: vi.fn() }
+ await service1.initializeIfNewUser(mockSettingStore)
+
+ // State should be shared
+ expect(service2.isNewUser()).toBe(true) // or false, depending on mock
+ })
+})
+```
+
+### Common Testing Patterns
+
+**Mocking External Dependencies:**
+```typescript
+// Mock stores
+vi.mock('@/stores/settingStore', () => ({
+ useSettingStore: () => ({
+ get: vi.fn(),
+ set: vi.fn()
+ })
+}))
+
+// Mock API calls
+vi.mock('@/scripts/api', () => ({
+ api: {
+ get: vi.fn().mockResolvedValue({ data: 'mock' }),
+ post: vi.fn().mockResolvedValue({ success: true })
+ }
+}))
+
+// Mock Vue composables
+vi.mock('vue', () => ({
+ ref: vi.fn((val) => ({ value: val })),
+ reactive: vi.fn((obj) => obj)
+}))
+```
+
+**Async Testing:**
+```typescript
+test('should handle async operations', async () => {
+ const service = useMyService()
+ const result = await service.performAsyncOperation()
+ expect(result).toBeTruthy()
+})
+
+test('should handle concurrent requests', async () => {
+ const service = useMyService()
+ const promises = [
+ service.loadData('key1'),
+ service.loadData('key2')
+ ]
+
+ const results = await Promise.all(promises)
+ expect(results).toHaveLength(2)
+})
+```
+
+**Error Handling:**
+```typescript
+test('should handle service errors gracefully', async () => {
+ const service = useMyService()
+
+ // Mock API to throw error
+ mockApi.get.mockRejectedValue(new Error('Network error'))
+
+ await expect(service.fetchData()).rejects.toThrow('Network error')
+})
+
+test('should provide meaningful error messages', async () => {
+ const service = useMyService()
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation()
+
+ await service.handleError('test error')
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test error'))
+})
+```
+
+### Testing Best Practices
+
+1. **Isolate Dependencies**: Always mock external dependencies (stores, APIs, DOM)
+2. **Reset State**: Use `beforeEach` to ensure clean test state
+3. **Test Error Paths**: Don't just test happy paths - test error scenarios
+4. **Mock Timers**: Use `vi.useFakeTimers()` for time-dependent services
+5. **Test Async Properly**: Use `async/await` and proper promise handling
+
For more detailed information about the service layer pattern and its applications, refer to:
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)
\ No newline at end of file
diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts
index 3f0ca9d9ab..abbe2ecb47 100644
--- a/src/services/dialogService.ts
+++ b/src/services/dialogService.ts
@@ -4,7 +4,6 @@ import { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
-import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
@@ -124,16 +123,6 @@ export const useDialogService = () => {
})
}
- function showIssueReportDialog(
- props: InstanceType['$props']
- ) {
- dialogStore.showDialog({
- key: 'global-issue-report',
- component: IssueReportDialogContent,
- props
- })
- }
-
function showManagerDialog(
props: InstanceType['$props'] = {}
) {
@@ -470,7 +459,6 @@ export const useDialogService = () => {
showAboutDialog,
showExecutionErrorDialog,
showTemplateWorkflowsDialog,
- showIssueReportDialog,
showManagerDialog,
showManagerProgressDialog,
showErrorDialog,
diff --git a/src/stores/README.md b/src/stores/README.md
index de45fdc71a..a24a106e57 100644
--- a/src/stores/README.md
+++ b/src/stores/README.md
@@ -100,57 +100,64 @@ The following diagram illustrates the store architecture and data flow:
## Core Stores
-The following table lists ALL stores in the system as of 2025-01-30:
+The following table lists ALL 46 store instances in the system as of 2025-09-01:
### Main Stores
-| Store | Description | Category |
-|-------|-------------|----------|
-| aboutPanelStore.ts | Manages the About panel state and badges | UI |
-| apiKeyAuthStore.ts | Handles API key authentication | Auth |
-| comfyManagerStore.ts | Manages ComfyUI application state | Core |
-| comfyRegistryStore.ts | Handles extensions registry | Registry |
-| commandStore.ts | Manages commands and command execution | Core |
-| dialogStore.ts | Controls dialog/modal display and state | UI |
-| domWidgetStore.ts | Manages DOM widget state | Widgets |
-| electronDownloadStore.ts | Handles Electron-specific download operations | Platform |
-| executionStore.ts | Tracks workflow execution state | Execution |
-| extensionStore.ts | Manages extension registration and state | Extensions |
-| firebaseAuthStore.ts | Handles Firebase authentication | Auth |
-| graphStore.ts | Manages the graph canvas state | Core |
-| imagePreviewStore.ts | Controls image preview functionality | Media |
-| keybindingStore.ts | Manages keyboard shortcuts | Input |
-| maintenanceTaskStore.ts | Handles system maintenance tasks | System |
-| menuItemStore.ts | Handles menu items and their state | UI |
-| modelStore.ts | Manages AI models information | Models |
-| modelToNodeStore.ts | Maps models to compatible nodes | Models |
-| nodeBookmarkStore.ts | Manages node bookmarks and favorites | Nodes |
-| nodeDefStore.ts | Manages node definitions | Nodes |
-| queueStore.ts | Handles the execution queue | Execution |
-| releaseStore.ts | Manages application release information | System |
-| serverConfigStore.ts | Handles server configuration | Config |
-| settingStore.ts | Manages application settings | Config |
-| subgraphNavigationStore.ts | Handles subgraph navigation state | Navigation |
-| systemStatsStore.ts | Tracks system performance statistics | System |
-| toastStore.ts | Manages toast notifications | UI |
-| userFileStore.ts | Manages user file operations | Files |
-| userStore.ts | Manages user data and preferences | User |
-| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core |
-| widgetStore.ts | Manages widget configurations | Widgets |
-| workflowStore.ts | Handles workflow data and operations | Workflows |
-| workflowTemplatesStore.ts | Manages workflow templates | Workflows |
-| workspaceStore.ts | Manages overall workspace state | Workspace |
+| File | Store | Description | Category |
+|------|-------|-------------|----------|
+| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
+| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
+| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
+| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
+| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
+| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
+| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |
+| domWidgetStore.ts | useDomWidgetStore | Manages DOM widget state | Widgets |
+| electronDownloadStore.ts | useElectronDownloadStore | Handles Electron-specific download operations | Platform |
+| executionStore.ts | useExecutionStore | Tracks workflow execution state | Execution |
+| extensionStore.ts | useExtensionStore | Manages extension registration and state | Extensions |
+| firebaseAuthStore.ts | useFirebaseAuthStore | Handles Firebase authentication | Auth |
+| graphStore.ts | useTitleEditorStore | Manages title editing for nodes and groups | UI |
+| graphStore.ts | useCanvasStore | Manages the graph canvas state and interactions | Core |
+| helpCenterStore.ts | useHelpCenterStore | Manages help center visibility and state | UI |
+| imagePreviewStore.ts | useNodeOutputStore | Manages node outputs and execution results | Media |
+| keybindingStore.ts | useKeybindingStore | Manages keyboard shortcuts | Input |
+| maintenanceTaskStore.ts | useMaintenanceTaskStore | Handles system maintenance tasks | System |
+| menuItemStore.ts | useMenuItemStore | Handles menu items and their state | UI |
+| modelStore.ts | useModelStore | Manages AI models information | Models |
+| modelToNodeStore.ts | useModelToNodeStore | Maps models to compatible nodes | Models |
+| nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes |
+| nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes |
+| nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes |
+| queueStore.ts | useQueueStore | Manages execution queue and task history | Execution |
+| queueStore.ts | useQueuePendingTaskCountStore | Tracks pending task counts | Execution |
+| queueStore.ts | useQueueSettingsStore | Manages queue execution settings | Execution |
+| releaseStore.ts | useReleaseStore | Manages application release information | System |
+| serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config |
+| settingStore.ts | useSettingStore | Manages application settings | Config |
+| subgraphNavigationStore.ts | useSubgraphNavigationStore | Handles subgraph navigation state | Navigation |
+| systemStatsStore.ts | useSystemStatsStore | Tracks system performance statistics | System |
+| toastStore.ts | useToastStore | Manages toast notifications | UI |
+| userFileStore.ts | useUserFileStore | Manages user file operations | Files |
+| userStore.ts | useUserStore | Manages user data and preferences | User |
+| versionCompatibilityStore.ts | useVersionCompatibilityStore | Manages frontend/backend version compatibility warnings | Core |
+| widgetStore.ts | useWidgetStore | Manages widget configurations | Widgets |
+| workflowStore.ts | useWorkflowStore | Handles workflow data and operations | Workflows |
+| workflowStore.ts | useWorkflowBookmarkStore | Manages workflow bookmarks and favorites | Workflows |
+| workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows |
+| workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace |
### Workspace Stores
Located in `stores/workspace/`:
-| Store | Description |
-|-------|-------------|
-| bottomPanelStore.ts | Controls bottom panel visibility and state |
-| colorPaletteStore.ts | Manages color palette configurations |
-| nodeHelpStore.ts | Handles node help and documentation display |
-| searchBoxStore.ts | Manages search box functionality |
-| sidebarTabStore.ts | Controls sidebar tab states and navigation |
+| File | Store | Description | Category |
+|------|-------|-------------|----------|
+| bottomPanelStore.ts | useBottomPanelStore | Controls bottom panel visibility and state | UI |
+| colorPaletteStore.ts | useColorPaletteStore | Manages color palette configurations | UI |
+| nodeHelpStore.ts | useNodeHelpStore | Handles node help and documentation display | UI |
+| searchBoxStore.ts | useSearchBoxStore | Manages search box functionality | UI |
+| sidebarTabStore.ts | useSidebarTabStore | Controls sidebar tab states and navigation | UI |
## Store Development Guidelines
@@ -189,7 +196,7 @@ export const useExampleStore = defineStore('example', () => {
async function fetchItems() {
isLoading.value = true
error.value = null
-
+
try {
const response = await fetch('/api/items')
const data = await response.json()
@@ -207,11 +214,11 @@ export const useExampleStore = defineStore('example', () => {
items,
isLoading,
error,
-
+
// Getters
itemCount,
hasError,
-
+
// Actions
addItem,
fetchItems
@@ -238,7 +245,7 @@ export const useDataStore = defineStore('data', () => {
async function fetchData() {
loading.value = true
try {
- const result = await api.getData()
+ const result = await api.getExtensions()
data.value = result
} catch (err) {
error.value = err.message
@@ -266,21 +273,21 @@ import { useOtherStore } from './otherStore'
export const useComposedStore = defineStore('composed', () => {
const otherStore = useOtherStore()
const { someData } = storeToRefs(otherStore)
-
+
// Local state
const localState = ref(0)
-
+
// Computed value based on other store
const derivedValue = computed(() => {
return computeFromOtherData(someData.value, localState.value)
})
-
+
// Action that uses another store
async function complexAction() {
await otherStore.someAction()
localState.value += 1
}
-
+
return {
localState,
derivedValue,
@@ -299,20 +306,20 @@ export const usePreferencesStore = defineStore('preferences', () => {
// Load from localStorage if available
const theme = ref(localStorage.getItem('theme') || 'light')
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
-
+
// Save to localStorage when changed
watch(theme, (newTheme) => {
localStorage.setItem('theme', newTheme)
})
-
+
watch(fontSize, (newSize) => {
localStorage.setItem('fontSize', newSize.toString())
})
-
+
function setTheme(newTheme) {
theme.value = newTheme
}
-
+
return {
theme,
fontSize,
@@ -347,7 +354,7 @@ describe('useExampleStore', () => {
// Create a fresh pinia instance and make it active
setActivePinia(createPinia())
store = useExampleStore()
-
+
// Clear all mocks
vi.clearAllMocks()
})
@@ -363,14 +370,14 @@ describe('useExampleStore', () => {
expect(store.items).toEqual(['test'])
expect(store.itemCount).toBe(1)
})
-
+
it('should fetch items', async () => {
// Setup mock response
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
-
+
// Call the action
await store.fetchItems()
-
+
// Verify state changes
expect(store.isLoading).toBe(false)
expect(store.items).toEqual(['item1', 'item2'])
diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts
index 65b468001a..161ec9d567 100644
--- a/src/stores/firebaseAuthStore.ts
+++ b/src/stores/firebaseAuthStore.ts
@@ -8,6 +8,7 @@ import {
type UserCredential,
browserLocalPersistence,
createUserWithEmailAndPassword,
+ deleteUser,
onAuthStateChanged,
sendPasswordResetEmail,
setPersistence,
@@ -287,6 +288,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
await updatePassword(currentUser.value, newPassword)
}
+ /** Delete the current user account */
+ const _deleteAccount = async (): Promise => {
+ if (!currentUser.value) {
+ throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
+ }
+ await deleteUser(currentUser.value)
+ }
+
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise => {
@@ -385,6 +394,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
accessBillingPortal,
sendPasswordReset,
updatePassword: _updatePassword,
+ deleteAccount: _deleteAccount,
getAuthHeader
}
})
diff --git a/src/types/issueReportTypes.ts b/src/types/issueReportTypes.ts
deleted file mode 100644
index 5b9c28abd2..0000000000
--- a/src/types/issueReportTypes.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-export type DefaultField = 'Workflow' | 'Logs' | 'SystemStats' | 'Settings'
-
-export interface ReportField {
- /**
- * The label of the field, shown next to the checkbox if the field is opt-in.
- */
- label: string
-
- /**
- * A unique identifier for the field, used internally as the key for this field's value.
- */
- value: string
-
- /**
- * The data associated with this field, sent as part of the report.
- */
- getData: () => unknown
-
- /**
- * Indicates whether the field requires explicit opt-in from the user
- * before its data is included in the report.
- */
- optIn: boolean
-}
-
-export interface IssueReportPanelProps {
- /**
- * The type of error being reported. This is used to categorize the error.
- */
- errorType: string
-
- /**
- * Which of the default fields to include in the report.
- */
- defaultFields?: DefaultField[]
-
- /**
- * Additional fields to include in the report.
- */
- extraFields?: ReportField[]
-
- /**
- * Tags that will be added to the report. Tags are used to further categorize the error.
- */
- tags?: Record
-
- /**
- * The title displayed in the dialog.
- */
- title?: string
-}
diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts
index 9881db2417..e6df949530 100644
--- a/src/utils/colorUtil.ts
+++ b/src/utils/colorUtil.ts
@@ -59,6 +59,59 @@ export function hexToRgb(hex: string): RGB {
return { r, g, b }
}
+export function parseToRgb(color: string): RGB {
+ const format = identifyColorFormat(color)
+ if (!format) return { r: 0, g: 0, b: 0 }
+
+ const hsla = parseToHSLA(color, format)
+ if (!isHSLA(hsla)) return { r: 0, g: 0, b: 0 }
+
+ // Convert HSL to RGB
+ const h = hsla.h / 360
+ const s = hsla.s / 100
+ const l = hsla.l / 100
+
+ const c = (1 - Math.abs(2 * l - 1)) * s
+ const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
+ const m = l - c / 2
+
+ let r = 0,
+ g = 0,
+ b = 0
+
+ if (h < 1 / 6) {
+ r = c
+ g = x
+ b = 0
+ } else if (h < 2 / 6) {
+ r = x
+ g = c
+ b = 0
+ } else if (h < 3 / 6) {
+ r = 0
+ g = c
+ b = x
+ } else if (h < 4 / 6) {
+ r = 0
+ g = x
+ b = c
+ } else if (h < 5 / 6) {
+ r = x
+ g = 0
+ b = c
+ } else {
+ r = c
+ g = 0
+ b = x
+ }
+
+ return {
+ r: Math.round((r + m) * 255),
+ g: Math.round((g + m) * 255),
+ b: Math.round((b + m) * 255)
+ }
+}
+
const identifyColorFormat = (color: string): ColorFormat | null => {
if (!color) return null
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
diff --git a/tests-ui/tests/composables/node/useNodePricing.test.ts b/tests-ui/tests/composables/node/useNodePricing.test.ts
index ed9d5eaccf..f6f45e6789 100644
--- a/tests-ui/tests/composables/node/useNodePricing.test.ts
+++ b/tests-ui/tests/composables/node/useNodePricing.test.ts
@@ -8,7 +8,12 @@ import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
function createMockNode(
nodeTypeName: string,
widgets: Array<{ name: string; value: any }> = [],
- isApiNode = true
+ isApiNode = true,
+ inputs: Array<{
+ name: string
+ connected?: boolean
+ useLinksArray?: boolean
+ }> = []
): LGraphNode {
const mockWidgets = widgets.map(({ name, value }) => ({
name,
@@ -16,7 +21,16 @@ function createMockNode(
type: 'combo'
})) as IComboWidget[]
- return {
+ const mockInputs =
+ inputs.length > 0
+ ? inputs.map(({ name, connected, useLinksArray }) =>
+ useLinksArray
+ ? { name, links: connected ? [1] : [] }
+ : { name, link: connected ? 1 : null }
+ )
+ : undefined
+
+ const node: any = {
id: Math.random().toString(),
widgets: mockWidgets,
constructor: {
@@ -25,7 +39,24 @@ function createMockNode(
api_node: isApiNode
}
}
- } as unknown as LGraphNode
+ }
+
+ if (mockInputs) {
+ node.inputs = mockInputs
+ // Provide the common helpers some frontend code may call
+ node.findInputSlot = function (portName: string) {
+ return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1
+ }
+ node.isInputConnected = function (idx: number) {
+ const port = this.inputs?.[idx]
+ if (!port) return false
+ if (typeof port.link !== 'undefined') return port.link != null
+ if (Array.isArray(port.links)) return port.links.length > 0
+ return false
+ }
+ }
+
+ return node as LGraphNode
}
describe('useNodePricing', () => {
@@ -363,34 +394,51 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - IdeogramV3', () => {
- it('should return $0.09 for Quality rendering speed', () => {
- const { getNodeDisplayPrice } = useNodePricing()
- const node = createMockNode('IdeogramV3', [
- { name: 'rendering_speed', value: 'Quality' }
- ])
-
- const price = getNodeDisplayPrice(node)
- expect(price).toBe('$0.09/Run')
- })
-
- it('should return $0.06 for Balanced rendering speed', () => {
- const { getNodeDisplayPrice } = useNodePricing()
- const node = createMockNode('IdeogramV3', [
- { name: 'rendering_speed', value: 'Balanced' }
- ])
-
- const price = getNodeDisplayPrice(node)
- expect(price).toBe('$0.06/Run')
- })
-
- it('should return $0.03 for Turbo rendering speed', () => {
- const { getNodeDisplayPrice } = useNodePricing()
- const node = createMockNode('IdeogramV3', [
- { name: 'rendering_speed', value: 'Turbo' }
- ])
-
- const price = getNodeDisplayPrice(node)
- expect(price).toBe('$0.03/Run')
+ it('should return correct prices for IdeogramV3 node', () => {
+ const { getNodeDisplayPrice } = useNodePricing()
+
+ const testCases = [
+ {
+ rendering_speed: 'Quality',
+ character_image: false,
+ expected: '$0.09/Run'
+ },
+ {
+ rendering_speed: 'Quality',
+ character_image: true,
+ expected: '$0.20/Run'
+ },
+ {
+ rendering_speed: 'Default',
+ character_image: false,
+ expected: '$0.06/Run'
+ },
+ {
+ rendering_speed: 'Default',
+ character_image: true,
+ expected: '$0.15/Run'
+ },
+ {
+ rendering_speed: 'Turbo',
+ character_image: false,
+ expected: '$0.03/Run'
+ },
+ {
+ rendering_speed: 'Turbo',
+ character_image: true,
+ expected: '$0.10/Run'
+ }
+ ]
+
+ testCases.forEach(({ rendering_speed, character_image, expected }) => {
+ const node = createMockNode(
+ 'IdeogramV3',
+ [{ name: 'rendering_speed', value: rendering_speed }],
+ true,
+ [{ name: 'character_image', connected: character_image }]
+ )
+ expect(getNodeDisplayPrice(node)).toBe(expected)
+ })
})
it('should return range when rendering_speed widget is missing', () => {
@@ -935,7 +983,11 @@ describe('useNodePricing', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV3')
- expect(widgetNames).toEqual(['rendering_speed', 'num_images'])
+ expect(widgetNames).toEqual([
+ 'rendering_speed',
+ 'num_images',
+ 'character_image'
+ ])
})
})
diff --git a/tsconfig.json b/tsconfig.json
index 73a9fca80f..675a099c33 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,16 +12,12 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
-
- /* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"downlevelIteration": true,
"noImplicitOverride": true,
-
- /* AllowJs during migration phase */
"allowJs": true,
"baseUrl": ".",
"paths": {
@@ -29,7 +25,7 @@
},
"typeRoots": ["src/types", "node_modules/@types"],
"outDir": "./dist",
- "rootDir": "./",
+ "rootDir": "./"
},
"include": [
"src/**/*",
diff --git a/vite.config.mts b/vite.config.mts
index a52e4c81c3..44feab7f16 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -26,6 +26,9 @@ export default defineConfig({
base: '',
server: {
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
+ watch: {
+ ignored: ['**/coverage/**', '**/playwright-report/**']
+ },
proxy: {
'/internal': {
target: DEV_SERVER_COMFYUI_URL