Compare commits

..

4 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
9715d3ba9e Merge branch 'main' into fix/node-shape-change 2026-02-16 20:34:53 +01:00
GitHub Action
bebccd9018 [automated] Apply ESLint and Oxfmt fixes 2026-02-12 01:52:18 +00:00
Johnpaul Chiwetelu
45259f1169 Merge branch 'main' into fix/node-shape-change 2026-02-12 02:50:10 +01:00
Johnpaul
536275cabe fix: preserve prototype getter/setter in property instrumentation
LGraphNodeProperties was shadowing LGraphNode's prototype shape
getter/setter with a closure-based own accessor. This caused
node.shape assignments to store values in a closure variable
instead of node._shape, making renderingShape always fall back
to the default round shape.

Skip instrumentation when a prototype accessor already exists,
since the prototype setter already emits change events.

Fixes #8532
2026-02-11 01:22:54 +01:00
177 changed files with 677 additions and 9473 deletions

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

@@ -1,205 +0,0 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,186 +0,0 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,86 +0,0 @@
{
"id": "save-image-and-webm-test",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 100],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1, 2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 11,
"type": "SaveImage",
"pos": [450, 100],
"size": [210, 270],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 12,
"type": "SaveWEBM",
"pos": [450, 450],
"size": [210, 368],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", "vp9", 6, 32]
}
],
"links": [
[1, 10, 0, 11, 0, "IMAGE"],
[2, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -215,14 +215,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
})
test('Does not add duplicate filter with same type and value', async ({
comfyPage
}) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.removeFilter(0)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,42 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Save Image and WEBM preview',
{ tag: ['@screenshot', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Can preview both SaveImage and SaveWEBM outputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30000
})
// Wait for SaveWEBM to render a video inside .video-preview
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
timeout: 30000
})
await expect(comfyPage.page).toHaveScreenshot(
'save-image-and-webm-preview.png'
)
})
}
)

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
}
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
const imagePreview = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test.fixme('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})

25
global.d.ts vendored
View File

@@ -10,28 +10,9 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
client_id: string | number | undefined
session_id: string | number | undefined
session_number: string | number | undefined
}
interface GtagFunction {
<TField extends GtagGetFieldName>(
command: 'get',
targetId: string,
fieldName: TField,
callback: (value: GtagGetFieldValueMap[TField]) => void
): void
(...args: unknown[]): void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -55,8 +36,12 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.8",
"version": "1.40.5",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -215,17 +215,6 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {

View File

@@ -36,14 +36,7 @@
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
@@ -67,7 +60,7 @@
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
@@ -75,12 +68,6 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
@@ -152,7 +139,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -175,7 +161,6 @@ import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@/utils/tailwindUtil'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -260,8 +245,6 @@ const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -3,26 +3,49 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({

View File

@@ -1,175 +0,0 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
</Button>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
step = 1,
disabled = false,
hideButtons = false,
displayValue,
parseValue
} = defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
hideButtons?: boolean
displayValue?: string
parseValue?: (raw: string) => number | undefined
inputAttrs?: Record<string, unknown>
}>()
const { t } = useI18n()
const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
const raw = target.value.trim()
const parsed = parseValue
? parseValue(raw)
: raw === ''
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
function handlePointerUp() {
if (!dragging.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-5 text-muted-foreground">
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,210 +14,32 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- MANUAL INSTALLATION REQUIRED Section -->
<!-- Missing Nodes List Wrapper -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
>
<span class="text-xs">
{{ node.label }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
</p>
</div>
</div>
@@ -225,39 +47,23 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const { missingNodeTypes } = defineProps<{
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
return missingNodeTypes
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
@@ -269,81 +75,10 @@ const uniqueNodes = computed<ProcessedNode[]>(() => {
return {
label: node.type,
hint: node.hint,
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
action: node.action
}
}
return { label: node, isReplaceable: false }
return { label: node }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -30,18 +30,8 @@
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
>
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<Button
variant="textonly"
size="sm"
@@ -58,9 +48,9 @@
}}</Button>
</div>
<!-- OSS mode: Manager buttons -->
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="handleOpenManager">{{
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
@@ -92,17 +82,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -124,12 +109,6 @@ function openShowMissingNodesSetting() {
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -149,29 +128,15 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -162,7 +162,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -120,12 +120,12 @@ import { useI18n } from 'vue-i18n'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'

View File

@@ -60,9 +60,6 @@
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@pointerdown.capture="forwardPanEvent"
@pointerup.capture="forwardPanEvent"
@pointermove.capture="forwardPanEvent"
>
<!-- Vue nodes rendered based on graph nodes -->
<LGraphNode
@@ -117,7 +114,6 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -164,7 +160,6 @@ import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -545,13 +540,4 @@ onMounted(async () => {
onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
return
canvasInteractions.forwardEventToCanvas(e)
}
</script>

View File

@@ -67,6 +67,18 @@ describe('HoneyToast', () => {
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
@@ -115,6 +127,11 @@ describe('HoneyToast', () => {
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -26,13 +26,13 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>

View File

@@ -28,7 +28,7 @@
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
class="block size-full object-contain select-none brightness-50"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
@@ -36,12 +36,14 @@
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
<div
v-for="handle in resizeHandles"
@@ -129,6 +131,7 @@ const {
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,

View File

@@ -52,7 +52,7 @@ export const Completed: Story = {
args: args({
type: 'completed',
count: 1,
thumbnailUrls: [thumbnail('4dabf7')]
thumbnailUrl: thumbnail('4dabf7')
})
}
@@ -97,7 +97,7 @@ export const Gallery: Story = {
const completed = args({
type: 'completed',
count: 1,
thumbnailUrls: [thumbnail('ff6b6b')]
thumbnailUrl: thumbnail('ff6b6b')
})
const completedMultiple = args({
type: 'completed',

View File

@@ -71,6 +71,11 @@ const thumbnailUrls = computed(() => {
if (notification.type !== 'completed') {
return []
}
if (typeof notification.thumbnailUrl === 'string') {
return notification.thumbnailUrl.length > 0
? [notification.thumbnailUrl]
: []
}
return notification.thumbnailUrls?.slice(0, 2) ?? []
})

View File

@@ -4,17 +4,46 @@
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
@clear-history="$emit('clearHistory')"
@clear-queued="$emit('clearQueued')"
/>
<div class="flex items-center justify-between px-3">
<Button
class="grow gap-1 justify-center"
variant="secondary"
size="sm"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</Button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<Button
v-if="queuedCount > 0"
class="ml-2"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
@@ -42,7 +71,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
JobGroup,
JobListItem,
@@ -81,6 +112,8 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)

View File

@@ -40,8 +40,6 @@ const i18n = createI18n({
sideToolbar: {
queueProgressOverlay: {
running: 'running',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
@@ -56,7 +54,6 @@ const mountHeader = (props = {}) =>
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
queuedCount: 3,
...props
},
global: {
@@ -83,25 +80,6 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('shows queued summary and emits clear queued', async () => {
const wrapper = mountHeader({ queuedCount: 4 })
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('queued')
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
})
it('hides clear queued button when queued count is zero', () => {
const wrapper = mountHeader({ queuedCount: 0 })
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
false
)
})
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')

View File

@@ -1,8 +1,8 @@
<template>
<div
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
>
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
<div class="px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
@@ -17,25 +17,6 @@
</span>
</span>
</div>
<div
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
>
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
v-if="queuedCount > 0"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
<div v-if="!isCloud" class="flex items-center gap-1">
<Button
v-tooltip.top="moreTooltipConfig"
@@ -97,12 +78,10 @@ defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()

View File

@@ -1,99 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const QueueOverlayExpandedStub = defineComponent({
name: 'QueueOverlayExpanded',
props: {
headerTitle: {
type: String,
required: true
}
},
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl({
id,
status,
create_time: 0,
priority: 0
})
}
const mountComponent = (
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
) => {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
const queueStore = useQueueStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
return mount(QueueProgressOverlay, {
props: {
expanded: true
},
global: {
plugins: [pinia, i18n],
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
},
directives: {
tooltip: () => {}
}
}
})
}
describe('QueueProgressOverlay', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('shows expanded header with running and queued labels', () => {
const wrapper = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
],
[createTask('pending-1', 'pending')]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'2 running, 1 queued'
)
})
it('shows only running label when queued count is zero', () => {
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
)
})
it('shows job queue title when there are no active jobs', () => {
const wrapper = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
})

View File

@@ -92,7 +92,7 @@ const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
}>()
const { t, n } = useI18n()
const { t } = useI18n()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
@@ -126,6 +126,7 @@ const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isExecuting = computed(() => !executionStore.isIdle)
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
@@ -155,34 +156,11 @@ const bottomRowClass = computed(
: 'opacity-0 pointer-events-none'
}`
)
const runningJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
count: n(runningCount.value)
})
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
: t('sideToolbar.queueProgressOverlay.jobQueue')
)
const queuedJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
count: n(queuedCount.value)
})
)
const headerTitle = computed(() => {
if (!hasActiveJob.value) {
return t('sideToolbar.queueProgressOverlay.jobQueue')
}
if (queuedCount.value === 0) {
return runningJobsLabel.value
}
if (runningCount.value === 0) {
return queuedJobsLabel.value
}
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
running: runningJobsLabel.value,
queued: queuedJobsLabel.value
})
})
const concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount

View File

@@ -1,79 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
expose({
hide: () => undefined,
toggle: (_event: Event) => undefined
})
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
all: 'All',
completed: 'Completed'
},
queue: {
jobList: {
sortMostRecent: 'Most recent',
sortTotalGenerationTime: 'Total generation time'
}
},
sideToolbar: {
queueProgressOverlay: {
filterJobs: 'Filter jobs',
filterBy: 'Filter by',
sortJobs: 'Sort jobs',
sortBy: 'Sort by',
showAssets: 'Show assets',
showAssetsPanel: 'Show assets panel',
filterAllWorkflows: 'All workflows',
filterCurrentWorkflow: 'Current workflow'
}
}
}
}
})
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
const showAssetsButton = wrapper.get(
'button[aria-label="Show assets panel"]'
)
await showAssetsButton.trigger('click')
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
})

View File

@@ -127,15 +127,6 @@
</template>
</div>
</Popover>
<Button
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
@@ -159,7 +150,6 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
@@ -175,9 +165,6 @@ const filterTooltipConfig = computed(() =>
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud

View File

@@ -33,7 +33,6 @@ import {
useFlatAndCategorizeSelectedItems
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
@@ -41,8 +40,6 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -105,10 +102,7 @@ const selectedNodeErrors = computed(() =>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (
selectedNodeErrors.value.length &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
@@ -116,18 +110,6 @@ const tabs = computed<RightSidePanelTabList>(() => {
})
}
if (
hasAnyError.value &&
!hasSelection.value &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('rightSidePanel.errors'),
value: 'errors',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
flattedItems.value.length > 1
@@ -316,8 +298,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabErrors v-if="activeTab === 'errors'" />
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>

View File

@@ -1,162 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
details: 'Input: text\nExpected: STRING'
}
]
}
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
details: ''
},
{
message: 'Value "NaN" is not a valid number for "strength".',
details: 'Expected: FLOAT [0.0 .. 1.0]'
}
]
}
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
details: [
'Traceback (most recent call last):',
' File "ksampler.py", line 142, in sample',
' samples = model.apply(latent)',
'RuntimeError: CUDA out of memory.'
].join('\n'),
isRuntimeError: true
}
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
errors: [
{
message:
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
}
}

View File

@@ -1,110 +0,0 @@
<template>
<div class="overflow-hidden">
<!-- Card Header (Node ID & Actions) -->
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle"
class="flex-1 text-sm text-muted-foreground truncate font-medium"
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
@click.stop="emit('locateNode', card.nodeId ?? '')"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
</div>
<!-- Multiple Errors within one Card -->
<div class="divide-y divide-interface-stroke/20 space-y-4">
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex flex-col gap-3"
>
<!-- Error Message -->
<p
v-if="error.message"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
>
{{ error.message }}
</p>
<!-- Traceback / Details -->
<div
v-if="error.details"
:class="
cn(
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
>
{{ error.details }}
</p>
</div>
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-[11px]"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
{{ t('g.copy') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const { card, showNodeIdBadge = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
const { t } = useI18n()
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',
[error.message, error.details].filter(Boolean).join('\n\n')
)
}
</script>

View File

@@ -1,218 +0,0 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
forEachNode: vi.fn()
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
}
})
})
function mountComponent(initialState = {}) {
return mount(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
FormSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
it('renders "no errors" state when store is empty', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
})
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
})
it('renders node validation errors grouped by class_type', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
})
it('renders runtime execution errors from WebSocket', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
})
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('calls copyToClipboard when copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
}
}
})
// Find the copy button (rendered inside ErrorNodeCard)
const copyButtons = wrapper.findAll('button')
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
})

View File

@@ -1,167 +0,0 @@
<template>
<div class="flex flex-col h-full min-w-0">
<!-- Search bar -->
<div
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
>
<FormSearchInput v-model="searchQuery" />
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto min-w-0">
<div
v-if="filteredGroups.length === 0"
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div v-else>
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<i
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
</span>
<span
v-if="group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
@locate-node="focusNode"
@enter-subgraph="enterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
</PropertiesAccordionItem>
</div>
</div>
<!-- Fixed Footer: Help Links -->
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
class="m-0 text-sm text-muted-foreground leading-tight break-words"
>
<template #github>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="openGitHubIssues"
>
{{ t('rightSidePanel.errorHelpGithub') }}
</Button>
</template>
<template #support>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="contactSupport"
>
{{ t('rightSidePanel.errorHelpSupport') }}
</Button>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from './useErrorGroups'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const rightSidePanelStore = useRightSidePanelStore()
const searchQuery = ref('')
const settingStore = useSettingStore()
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
const { filteredGroups } = useErrorGroups(searchQuery, t)
const collapseState = reactive<Record<string, boolean>>({})
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
for (const group of filteredGroups.value) {
const hasMatch = group.cards.some(
(card) => card.graphNodeId === graphNodeId
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,21 +0,0 @@
export interface ErrorItem {
message: string
details?: string
isRuntimeError?: boolean
}
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -1,236 +0,0 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup } from './types'
import { isNodeExecutionId } from '@/types/nodeIdentification'
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
}
interface ErrorSearchItem {
groupIndex: number
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableMessage: string
searchableDetails: string
}
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
function resolveNodeInfo(nodeId: string): {
title: string
graphNodeId: string | undefined
} {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
title: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
}
}
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
}
function processPromptError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
) {
if (!executionStore.lastPromptError) return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
cards.set('__prompt__', {
id: '__prompt__',
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `node-${nodeId}`,
title: nodeError.class_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) continue
card.errors.push(
...nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
}))
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const nodeId = String(e.node_id)
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `exec-${nodeId}`,
title: e.node_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push({
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
})
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
})
}
function buildErrorGroups(
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
): ErrorGroup[] {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, executionStore, t)
processNodeErrors(groupsMap, executionStore)
processExecutionError(groupsMap, executionStore)
return toSortedGroups(groupsMap)
}
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]!
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]!
searchableList.push({
groupIndex: gi,
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query)
const matchedCardKeys = new Set(
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const errorGroups = computed<ErrorGroup[]>(() =>
buildErrorGroups(executionStore, t)
)
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(errorGroups.value, query)
})
return {
errorGroups,
filteredGroups
}
}

View File

@@ -12,10 +12,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -64,8 +60,6 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -110,11 +104,6 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const nodeHasError = computed(() => {
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -133,13 +122,6 @@ function handleLocateNode() {
}
}
function navigateToErrorTab() {
if (!targetNode.value) return
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
@@ -180,20 +162,9 @@ defineExpose({
:tooltip
>
<template #label>
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<i
v-if="nodeHasError"
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span
:class="
cn(
'truncate',
nodeHasError && 'text-destructive-background-hover'
)
"
>
<span class="truncate">
<slot name="label">
{{ displayLabel }}
</slot>
@@ -206,15 +177,6 @@ defineExpose({
{{ parentGroup.title }}
</span>
</span>
<Button
v-if="nodeHasError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}
</Button>
<Button
v-if="!isEmpty"
variant="textonly"

View File

@@ -1,173 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const mockStoreRefs = vi.hoisted(() => ({
visible: { value: false },
newSearchBoxEnabled: { value: true }
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('pinia', async () => {
const actual = await vi.importActual('pinia')
return {
...(actual as Record<string, unknown>),
storeToRefs: () => mockStoreRefs
}
})
vi.mock('@/stores/workspace/searchBoxStore', () => ({
useSearchBoxStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
addNodeOnGraph: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: null,
getCanvas: vi.fn(() => ({
linkConnector: {
events: new EventTarget(),
renderLinks: []
}
}))
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeSearchService: {
nodeFilters: [],
inputTypeFilter: {},
outputTypeFilter: {}
}
})
}))
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
template: '<div class="node-search-box" />'
})
function createFilter(
id: string,
value: string
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
return {
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
value
}
}
describe('NodeSearchBoxPopover', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
beforeEach(() => {
setActivePinia(createPinia())
mockStoreRefs.visible.value = false
})
const mountComponent = () => {
return mount(NodeSearchBoxPopover, {
global: {
plugins: [i18n, PrimeVue],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
Dialog: {
template: '<div><slot name="container" /></div>',
props: ['visible', 'modal', 'dismissableMask', 'pt']
}
}
}
})
}
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
const filters = searchBox.props('filters') as FuseFilterWithValue<
ComfyNodeDefImpl,
string
>[]
expect(filters).toHaveLength(1)
expect(filters[0]).toEqual(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'outputType' }),
value: 'IMAGE'
})
)
})
it('should not add a duplicate filter with same id and value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(1)
})
it('should allow filters with same id but different values', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
})
it('should allow filters with different ids but same value', async () => {
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(searchBox.props('filters')).toHaveLength(2)
})
})
})

View File

@@ -71,12 +71,7 @@ function getNewNodeLocation(): Point {
}
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
const isDuplicate = nodeFilters.value.some(
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
)
if (!isDuplicate) {
nodeFilters.value.push(filter)
}
nodeFilters.value.push(filter)
}
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value = nodeFilters.value.filter(

View File

@@ -79,21 +79,8 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
>
<div
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
class="flex flex-col gap-2 p-2"
>
<Skeleton class="aspect-square w-full rounded-lg" />
<div class="flex flex-col gap-1">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
@@ -219,7 +206,6 @@
<script setup lang="ts">
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
@@ -227,6 +213,7 @@ import {
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -238,7 +225,6 @@ const Load3dViewerContent = () =>
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
@@ -251,7 +237,6 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
@@ -275,7 +260,6 @@ const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
@@ -392,24 +376,7 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const DEFAULT_SKELETON_COUNT = 6
const skeletonCount = computed(() =>
expectedFolderCount.value > 0
? expectedFolderCount.value
: DEFAULT_SKELETON_COUNT
)
const {
state: folderAssets,
isLoading: folderLoading,
error: folderError,
execute: loadFolderAssets
} = useAsyncState(
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
resolveOutputAssetItems(metadata, options),
[] as AssetItem[],
{ immediate: false, resetOnExecute: true }
)
const folderAssets = ref<AssetItem[]>([])
// Base assets before search filtering
const baseAssets = computed(() => {
@@ -447,13 +414,9 @@ const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)
const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(
() =>
(loading.value || isFolderLoading.value) &&
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -461,7 +424,6 @@ const showLoadingState = computed(
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -637,25 +599,27 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
if (folderError.value) {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
exitFolderView()
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
}
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = folderItems
}
const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
expectedFolderCount.value = 0
folderAssets.value = []
searchQuery.value = ''
}

View File

@@ -29,7 +29,7 @@ import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()

View File

@@ -69,7 +69,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, defineAsyncComponent, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -80,8 +80,7 @@ import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() =>
import('../../platform/workspace/components/CurrentUserPopoverWorkspace.vue')
() => import('./CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{

View File

@@ -207,8 +207,8 @@ import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -112,9 +112,9 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
SubscriptionTier,
WorkspaceRole,

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('animate-pulse rounded-md bg-secondary-background', className)"
/>
</template>

View File

@@ -18,7 +18,7 @@ import type {
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)

View File

@@ -16,7 +16,7 @@ import type {
BillingActions,
BillingState,
SubscriptionInfo
} from '../../../composables/billing/types'
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.

View File

@@ -1,56 +0,0 @@
import { nextTick } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return
if (canvas.graph !== targetGraph) {
canvas.subgraph = targetGraph.isRootGraph
? undefined
: (targetGraph as Subgraph)
canvas.setGraph(targetGraph)
await nextTick()
// Double RAF to wait for LiteGraph's internal canvas frame cycle
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
}
}
export function useFocusNode() {
const canvasStore = useCanvasStore()
async function focusNode(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
}
}

View File

@@ -23,6 +23,7 @@ type QueueQueuedNotification = {
type QueueCompletedNotification = {
type: 'completed'
count: number
thumbnailUrl?: string
thumbnailUrls?: string[]
}
@@ -37,7 +38,7 @@ export type QueueNotificationBanner =
| QueueFailedNotification
const sanitizeCount = (value: number | undefined) => {
if (!(typeof value === 'number' && value > 0)) {
if (value === undefined || Number.isNaN(value) || value <= 0) {
return 1
}
return Math.floor(value)

View File

@@ -1,18 +1,15 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
describe('useErrorHandling', () => {
let errorHandler: ReturnType<typeof useErrorHandling>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createPinia())
errorHandler = useErrorHandling()
})
@@ -323,45 +320,6 @@ describe('useErrorHandling', () => {
})
})
describe('network error detection', () => {
it.each([
['Failed to fetch', 'Chrome/Edge'],
['NetworkError when attempting to fetch resource.', 'Firefox'],
['Load failed', 'Safari']
])('should show disconnected toast for "%s" (%s)', async (message) => {
const action = vi.fn(async () => {
throw new TypeError(message)
})
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
await wrapped()
const toastStore = useToastStore()
expect(toastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: t('g.disconnectedFromBackend')
})
)
})
it('should not treat non-TypeError as network error', async () => {
const action = vi.fn(async () => {
throw new Error('Failed to fetch')
})
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
await wrapped()
const toastStore = useToastStore()
expect(toastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
detail: 'Failed to fetch'
})
)
})
})
describe('backward compatibility', () => {
it('should work without recovery strategies parameter', async () => {
const action = vi.fn(async () => 'success')

View File

@@ -53,8 +53,7 @@ export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: unknown) => {
const isNetworkError =
error instanceof TypeError &&
/failed to fetch|networkerror|load failed/i.test(error.message)
error instanceof TypeError && error.message === 'Failed to fetch'
const message = isNetworkError
? t('g.disconnectedFromBackend')
: error instanceof Error

View File

@@ -20,8 +20,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
USER_SECRETS_ENABLED = 'user_secrets_enabled'
}
/**
@@ -97,9 +96,6 @@ export function useFeatureFlags() {
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
}
})

View File

@@ -155,18 +155,11 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const getInputImageUrl = (): string | null => {
if (!node.value) return null
let sourceNode = node.value.getInputNode(0)
if (!sourceNode) return null
const inputNode = node.value.getInputNode(0)
if (sourceNode.isSubgraphNode()) {
const link = node.value.getInputLink(0)
if (!link) return null
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
sourceNode = resolved?.outputNode ?? null
if (!sourceNode) return null
}
if (!inputNode) return null
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
if (urls?.length) {
return urls[0]
@@ -243,6 +236,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
height: `${cropHeight.value * scaleFactor.value}px`
}))
const cropImageStyle = computed(() => {
if (!imageUrl.value) return {}
return {
backgroundImage: `url(${imageUrl.value})`,
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
backgroundRepeat: 'no-repeat'
}
})
interface ResizeHandle {
direction: ResizeDirection
class: string
@@ -558,10 +562,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = app.rootGraph?.getNodeById(nodeId) || null
}
updateImageUrl()
@@ -594,6 +595,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,

View File

@@ -201,10 +201,11 @@ describe('pasteImageNodes', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const fileList = createDataTransfer([file1, file2]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
[file1, file2]
fileList
)
expect(createNode).toHaveBeenCalledTimes(2)
@@ -216,9 +217,11 @@ describe('pasteImageNodes', () => {
})
it('should handle empty file list', async () => {
const fileList = createDataTransfer([]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
[]
fileList
)
expect(createNode).not.toHaveBeenCalled()

View File

@@ -96,7 +96,7 @@ export async function pasteImageNode(
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: File[]
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []

View File

@@ -1,5 +1,3 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -7,38 +5,6 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -157,7 +123,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.textContent = options.title
element.innerHTML = options.title
root.append(element)
}
@@ -252,18 +218,11 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const label = name === null ? '' : String(name)
const innerHtml = name === null ? '' : String(name)
if (typeof value === 'string') {
element.textContent = label
element.innerHTML = innerHtml
} else {
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
element.innerHTML = value?.title ?? innerHtml
if (value.disabled) {
disabled = true

View File

@@ -68,6 +68,37 @@ describe('LGraphNodeProperties', () => {
})
})
describe('prototype accessor preservation', () => {
it('should not shadow prototype getter/setter with closure-based accessor', () => {
// Mirrors LGraphNode.shape / _shape pattern
class NodeWithShape {
_shape: number | undefined = undefined
id = 1
flags = {}
graph = mockGraph
title = 'test'
get shape(): number | undefined {
return this._shape
}
set shape(v: number) {
this._shape = v
}
}
const node = new NodeWithShape()
new LGraphNodeProperties(node as Partial<LGraphNode> as LGraphNode)
// The prototype getter/setter should NOT be shadowed
expect(Object.prototype.hasOwnProperty.call(node, 'shape')).toBe(false)
// Before fix: the closure-based accessor would shadow the prototype,
// storing the value in a closure and leaving _shape unchanged.
node.shape = 42
expect(node._shape).toBe(42)
expect(node.shape).toBe(42)
})
})
describe('isTracked', () => {
it('should correctly identify tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)

View File

@@ -89,6 +89,16 @@ export class LGraphNodeProperties {
const currentValue = targetObject[propertyName]
if (!hasProperty) {
// Check if a prototype in the chain defines a getter/setter for this
// property. Defining an own closure-based accessor would shadow the
// prototype accessor and break its internal logic (e.g. the `shape`
// setter that writes `_shape`). Skip instrumentation in that case
// the prototype setter is expected to emit its own change events.
if (this._hasPrototypeAccessor(targetObject, propertyName)) {
this._instrumentedPaths.add(path)
return
}
let value: unknown = undefined
Object.defineProperty(targetObject, propertyName, {
@@ -128,6 +138,23 @@ export class LGraphNodeProperties {
this._instrumentedPaths.add(path)
}
/**
* Checks whether any prototype in the chain defines a getter/setter for
* the given property.
*/
private _hasPrototypeAccessor(
obj: Record<string, unknown>,
propertyName: string
): boolean {
let proto = Object.getPrototypeOf(obj)
while (proto) {
const desc = Object.getOwnPropertyDescriptor(proto, propertyName)
if (desc && (desc.get || desc.set)) return true
proto = Object.getPrototypeOf(proto)
}
return false
}
/**
* Creates a property descriptor that emits change events
*/

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
"AUDIO_RECORD": "تسجيل صوتي",
"BOOLEAN": "منطقي",
"BOUNDING_BOX": "مربع التحديد",
"CAMERA_CONTROL": "تحكم الكاميرا",
"CLIP": "CLIP",
"CLIP_VISION": "رؤية CLIP",
@@ -1899,25 +1898,6 @@
"outputs": "المُخرجات",
"type": "النوع"
},
"nodeReplacement": {
"compatibleAlternatives": "بدائل متوافقة",
"installMissingNodes": "تثبيت العقد المفقودة",
"installationRequired": "التثبيت مطلوب",
"instructionMessage": "يجب عليك تثبيت هذه العقد أو استبدالها ببدائل مثبتة لتشغيل سير العمل. العقد المفقودة مميزة باللون {red} على اللوحة. بعض العقد لا يمكن استبدالها ويجب تثبيتها عبر مدير العقد.",
"notReplaceable": "التثبيت مطلوب",
"openNodeManager": "فتح مدير العقد",
"quickFixAvailable": "إصلاح سريع متاح",
"redHighlight": "أحمر",
"replaceFailed": "فشل في استبدال العقد",
"replaceSelected": "استبدال المحدد ({count})",
"replaceWarning": "سيؤدي هذا إلى تعديل سير العمل بشكل دائم. احفظ نسخة أولاً إذا لم تكن متأكدًا.",
"replaceable": "قابل للاستبدال",
"replaced": "تم الاستبدال",
"replacedAllNodes": "تم استبدال {count} نوع/أنواع من العقد",
"replacedNode": "تم استبدال العقدة: {nodeType}",
"selectAll": "تحديد الكل",
"skipForNow": "تخطي الآن"
},
"nodeTemplates": {
"enterName": "أدخل الاسم",
"saveAsTemplate": "حفظ كقالب"
@@ -2016,11 +1996,6 @@
"advancedInputs": "مدخلات متقدمة",
"bypass": "تجاوز",
"color": "لون العقدة",
"enterSubgraph": "دخول الرسم الفرعي",
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
"errorHelpGithub": "إرسال مشكلة على GitHub",
"errorHelpSupport": "تواصل مع الدعم الفني",
"errors": "الأخطاء",
"fallbackGroupTitle": "مجموعة",
"fallbackNodeTitle": "عقدة",
"favorites": "المدخلات المفضلة",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "العقدة ليس لديها مدخلات",
"locateNode": "تحديد موقع العقدة على اللوحة",
"mute": "كتم",
"noErrors": "لا توجد أخطاء",
"noSelection": "حدد عقدة لعرض خصائصها ومعلوماتها.",
"nodeState": "حالة العقدة",
"nodes": "العقد",
@@ -2064,19 +2038,10 @@
"normal": "عادي",
"parameters": "المعلمات",
"pinned": "مثبت",
"promptErrors": {
"no_prompt": {
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
},
"prompt_no_outputs": {
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة، معاينة الصورة) لإنتاج نتيجة."
}
},
"properties": "الخصائص",
"removeFavorite": "إزالة من المفضلة",
"resetAllParameters": "إعادة تعيين جميع المعلمات",
"resetToDefault": "إعادة التعيين إلى الافتراضي",
"seeError": "عرض الخطأ",
"settings": "الإعدادات",
"showAdvancedInputsButton": "إظهار المدخلات المتقدمة",
"showInput": "إظهار المدخل",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "لوحات ألوان مخصصة",
"DevMode": "وضع المطور",
"EditTokenWeight": "تعديل وزن الرمز",
"Error System": "نظام الأخطاء",
"Execution": "التنفيذ",
"Extension": "الإضافة",
"General": "عام",
@@ -2450,11 +2414,8 @@
"moreOptions": "خيارات إضافية",
"noActiveJobs": "لا توجد مهام نشطة",
"preview": "معاينة",
"queuedJobsLabel": "{count} في الانتظار",
"queuedSuffix": "في الانتظار",
"running": "قيد التشغيل",
"runningJobsLabel": "{count} قيد التشغيل",
"runningQueuedSummary": "{running} قيد التشغيل، {queued} في الانتظار",
"showAssets": "عرض الأصول",
"showAssetsPanel": "عرض لوحة الأصول",
"sortBy": "ترتيب حسب",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "موازن الصوت (٣ نطاقات)",
"inputs": {
"audio": {
"name": "الصوت"
},
"high_freq": {
"name": "تردد الحدة",
"tooltip": "تردد القطع للنطاق العالي"
},
"high_gain_dB": {
"name": "تعزيز الحدة (ديسيبل)",
"tooltip": "التحكم في مستوى الترددات العالية (الحدة)"
},
"low_freq": {
"name": "تردد الجهير",
"tooltip": "تردد القطع للنطاق المنخفض"
},
"low_gain_dB": {
"name": "تعزيز الجهير (ديسيبل)",
"tooltip": "التحكم في مستوى الترددات المنخفضة (الجهير)"
},
"mid_freq": {
"name": "تردد الوسطى",
"tooltip": "تردد المركز للنطاق المتوسط"
},
"mid_gain_dB": {
"name": "تعزيز الوسطى (ديسيبل)",
"tooltip": "التحكم في مستوى الترددات المتوسطة"
},
"mid_q": {
"name": "عامل Q للوسطى",
"tooltip": "معامل Q (عرض النطاق) للترددات المتوسطة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "دمج مسارين صوتيين عن طريق تراكب موجاتهما.",
"display_name": "دمج الصوت",
@@ -4283,26 +4242,6 @@
}
}
},
"ImageCropV2": {
"display_name": "قص الصورة",
"inputs": {
"crop_region": {
"name": "منطقة القص"
},
"height": {},
"image": {
"name": "الصورة"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "إزالة تكرار الصور",
"inputs": {
@@ -10399,32 +10338,6 @@
}
}
},
"NAGuidance": {
"description": "يطبق توجيه الانتباه المعياري على النماذج، مما يتيح استخدام المطالبات السلبية على النماذج المقطرة/schnell.",
"display_name": "توجيه الانتباه المعياري",
"inputs": {
"model": {
"name": "النموذج",
"tooltip": "النموذج الذي سيتم تطبيق NAG عليه."
},
"nag_alpha": {
"name": "معامل المزج",
"tooltip": "معامل المزج للانتباه المعياري. القيمة 1.0 تعني استبدال كامل، 0.0 تعني عدم وجود تأثير."
},
"nag_scale": {
"name": "عامل مقياس التوجيه",
"tooltip": "عامل مقياس التوجيه. القيم الأعلى تدفع أبعد عن المطالبة السلبية."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "النموذج المعدل مع تفعيل NAG."
}
}
},
"NormalizeImages": {
"display_name": "تطبيع الصور",
"inputs": {
@@ -11236,28 +11149,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "مربع التحديد",
"inputs": {
"height": {
"name": "الارتفاع"
},
"width": {
"name": "العرض"
},
"x": {
"name": "س"
},
"y": {
"name": "ص"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "عائم",
"inputs": {
@@ -11821,88 +11712,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "ينتج صورًا باستخدام نماذج Recraft V4 أو V4 Pro.",
"display_name": "Recraft V4 تحويل النص إلى صورة",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم في التوليد."
},
"model_size": {
"name": "الحجم"
},
"n": {
"name": "عدد الصور",
"tooltip": "عدد الصور المراد إنشاؤها."
},
"negative_prompt": {
"name": "المطالبة السلبية",
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
},
"recraft_controls": {
"name": "عناصر تحكم Recraft",
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "ينتج SVG باستخدام نماذج Recraft V4 أو V4 Pro.",
"display_name": "Recraft V4 تحويل النص إلى متجه",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم في التوليد."
},
"model_size": {
"name": "الحجم"
},
"n": {
"name": "عدد الصور",
"tooltip": "عدد الصور المراد إنشاؤها."
},
"negative_prompt": {
"name": "المطالبة السلبية",
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
},
"recraft_controls": {
"name": "عناصر تحكم Recraft",
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "ينشئ SVG بشكل متزامن من صورة إدخال.",
"display_name": "إعادة صياغة تحويل الصورة إلى متجه",
@@ -16037,46 +15846,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "إنشاء فيديو من إطار بداية، إطار نهاية، ونص توجيهي.",
"display_name": "توليد فيديو من إطار البداية/النهاية باستخدام Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"end_frame": {
"name": "إطار النهاية"
},
"first_frame": {
"name": "إطار البداية"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتوليد الفيديو."
},
"model_audio": {
"name": "الصوت"
},
"model_duration": {
"name": "المدة"
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "النص التوجيهي",
"tooltip": "وصف النص التوجيهي (بحد أقصى ٢٠٠٠ حرف)."
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "إنشاء فيديو من نص.",
"display_name": "توليد فيديو من نص Vidu Q3",

View File

@@ -350,10 +350,6 @@
"name": "استخدم قائمة انتظار المهام الموحدة في لوحة الأصول الجانبية",
"tooltip": "يستبدل لوحة قائمة انتظار المهام العائمة بقائمة انتظار مهام مكافئة مدمجة في لوحة الأصول الجانبية. يمكنك تعطيل هذا الخيار للعودة إلى تخطيط اللوحة العائمة."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "عرض تب الأخطاء في اللوحة الجانبية",
"tooltip": "عند التفعيل، سيتم عرض تب الأخطاء في اللوحة الجانبية اليمنى لعرض أخطاء تنفيذ سير العمل بسرعة."
},
"Comfy_Sidebar_Location": {
"name": "موقع الشريط الجانبي",
"options": {

View File

@@ -743,10 +743,6 @@
"filterText": "Text"
},
"backToAssets": "Back to all assets",
"folderView": {
"errorSummary": "Failed to load outputs",
"errorDetail": "Could not retrieve outputs for this job. Please try again."
},
"searchAssets": "Search Assets",
"labels": {
"queue": "Queue",
@@ -818,9 +814,6 @@
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"runningJobsLabel": "{count} running",
"queuedJobsLabel": "{count} queued",
"runningQueuedSummary": "{running}, {queued}",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
"viewJobHistory": "View active jobs (right-click to clear queue)",
@@ -1362,8 +1355,7 @@
"PLY": "PLY",
"Workspace": "Workspace",
"Other": "Other",
"Secrets": "Secrets",
"Error System": "Error System"
"Secrets": "Secrets"
},
"serverConfigItems": {
"listen": {
@@ -1572,7 +1564,6 @@
"MiniMax": "MiniMax",
"model_specific": "model_specific",
"Moonvalley Marey": "Moonvalley Marey",
"": "",
"OpenAI": "OpenAI",
"Sora": "Sora",
"cond pair": "cond pair",
@@ -1597,6 +1588,7 @@
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
"": "",
"camera": "camera",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
@@ -1609,7 +1601,6 @@
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CAMERA_CONTROL",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -2823,10 +2814,9 @@
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
@@ -2910,25 +2900,6 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
},
"nodeReplacement": {
"quickFixAvailable": "Quick Fix Available",
"installationRequired": "Installation Required",
"compatibleAlternatives": "Compatible Alternatives",
"replaceable": "Replaceable",
"replaced": "Replaced",
"notReplaceable": "Install Required",
"selectAll": "Select All",
"replaceSelected": "Replace Selected ({count})",
"replacedNode": "Replaced node: {nodeType}",
"replacedAllNodes": "Replaced {count} node type(s)",
"replaceFailed": "Failed to replace nodes",
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
"redHighlight": "red",
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",
@@ -2984,21 +2955,6 @@
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs",
"errors": "Errors",
"noErrors": "No errors",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"promptErrors": {
"prompt_no_outputs": {
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
}
},
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters"
},
@@ -3022,20 +2978,6 @@
"failed": "Failed"
}
},
"exportToast": {
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",
"exportCompleted": "ZIP download ready",
"exportFailedSingle": "Failed to create ZIP export",
"downloadExport": "Download export",
"downloadFailed": "Failed to download \"{name}\"",
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "Audio Equalizer (3-Band)",
"inputs": {
"audio": {
"name": "audio"
},
"low_gain_dB": {
"name": "low_gain_dB",
"tooltip": "Gain for Low frequencies (Bass)"
},
"low_freq": {
"name": "low_freq",
"tooltip": "Cutoff frequency for Low shelf"
},
"mid_gain_dB": {
"name": "mid_gain_dB",
"tooltip": "Gain for Mid frequencies"
},
"mid_freq": {
"name": "mid_freq",
"tooltip": "Center frequency for Mids"
},
"mid_q": {
"name": "mid_q",
"tooltip": "Q factor (bandwidth) for Mids"
},
"high_gain_dB": {
"name": "high_gain_dB",
"tooltip": "Gain for High frequencies (Treble)"
},
"high_freq": {
"name": "high_freq",
"tooltip": "Cutoff frequency for High shelf"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"display_name": "Audio Merge",
"description": "Combine two audio tracks by overlaying their waveforms.",
@@ -4270,7 +4229,7 @@
}
},
"ImageCrop": {
"display_name": "Image Crop (Deprecated)",
"display_name": "Image Crop",
"inputs": {
"image": {
"name": "image"
@@ -4294,26 +4253,6 @@
}
}
},
"ImageCropV2": {
"display_name": "Image Crop",
"inputs": {
"image": {
"name": "image"
},
"crop_region": {
"name": "crop_region"
},
"height": {},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "Image Deduplication",
"inputs": {
@@ -10460,32 +10399,6 @@
}
}
},
"NAGuidance": {
"display_name": "Normalized Attention Guidance",
"description": "Applies Normalized Attention Guidance to models, enabling negative prompts on distilled/schnell models.",
"inputs": {
"model": {
"name": "model",
"tooltip": "The model to apply NAG to."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "The guidance scale factor. Higher values push further from the negative prompt."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Blending factor for the normalized attention. 1.0 is full replacement, 0.0 is no effect."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "The patched model with NAG enabled."
}
}
},
"NormalizeImages": {
"display_name": "Normalize Images",
"inputs": {
@@ -11297,28 +11210,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "Bounding Box",
"inputs": {
"x": {
"name": "x"
},
"y": {
"name": "y"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "Float",
"inputs": {
@@ -11756,7 +11647,7 @@
},
"RecraftStyleV3InfiniteStyleLibrary": {
"display_name": "Recraft Style - Infinite Style Library",
"description": "Choose style based on preexisting UUID from Recraft's Infinite Style Library.",
"description": "Select style based on preexisting UUID from Recraft's Infinite Style Library.",
"inputs": {
"style_id": {
"name": "style_id",
@@ -11882,88 +11773,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"display_name": "Recraft V4 Text to Image",
"description": "Generates images using Recraft V4 or V4 Pro models.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "An optional text description of undesired elements on an image."
},
"model": {
"name": "model",
"tooltip": "The model to use for generation."
},
"n": {
"name": "n",
"tooltip": "The number of images to generate."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
},
"control_after_generate": {
"name": "control after generate"
},
"model_size": {
"name": "size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"display_name": "Recraft V4 Text to Vector",
"description": "Generates SVG using Recraft V4 or V4 Pro models.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "An optional text description of undesired elements on an image."
},
"model": {
"name": "model",
"tooltip": "The model to use for generation."
},
"n": {
"name": "n",
"tooltip": "The number of images to generate."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
},
"control_after_generate": {
"name": "control after generate"
},
"model_size": {
"name": "size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"display_name": "Recraft Vectorize Image",
"description": "Generates SVG synchronously from an input image.",
@@ -16175,46 +15984,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"display_name": "Vidu Q3 Start/End Frame-to-Video Generation",
"description": "Generate a video from a start frame, an end frame, and a prompt.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Model to use for video generation."
},
"first_frame": {
"name": "first_frame"
},
"end_frame": {
"name": "end_frame"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt description (max 2000 characters)."
},
"seed": {
"name": "seed"
},
"control_after_generate": {
"name": "control after generate"
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "duration"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"display_name": "Vidu Q3 Text-to-Video Generation",
"description": "Generate video from a text prompt.",

View File

@@ -285,8 +285,8 @@
"name": "Show API node pricing badge"
},
"Comfy_NodeReplacement_Enabled": {
"name": "Enable node replacement suggestions",
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
"name": "Enable automatic node replacement",
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",
@@ -350,10 +350,6 @@
"name": "Batch count limit",
"tooltip": "The maximum number of tasks added to the queue at one button click"
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Show errors tab in side panel",
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
},
"Comfy_Sidebar_Location": {
"name": "Sidebar location",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
"AUDIO_RECORD": "GRABACIÓN_AUDIO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "CUADRO DELIMITADOR",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -1899,25 +1898,6 @@
"outputs": "Salidas",
"type": "Tipo"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternativas compatibles",
"installMissingNodes": "Instalar nodos faltantes",
"installationRequired": "Instalación requerida",
"instructionMessage": "Debes instalar estos nodos o reemplazarlos por alternativas instaladas para ejecutar el flujo de trabajo. Los nodos faltantes están resaltados en {red} en el lienzo. Algunos nodos no se pueden intercambiar y deben instalarse mediante el Administrador de Nodos.",
"notReplaceable": "Instalación requerida",
"openNodeManager": "Abrir Administrador de Nodos",
"quickFixAvailable": "Solución rápida disponible",
"redHighlight": "rojo",
"replaceFailed": "Error al reemplazar nodos",
"replaceSelected": "Reemplazar seleccionados ({count})",
"replaceWarning": "Esto modificará permanentemente el flujo de trabajo. Guarda una copia primero si no estás seguro.",
"replaceable": "Reemplazable",
"replaced": "Reemplazado",
"replacedAllNodes": "Reemplazados {count} tipo(s) de nodo",
"replacedNode": "Nodo reemplazado: {nodeType}",
"selectAll": "Seleccionar todo",
"skipForNow": "Omitir por ahora"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
"saveAsTemplate": "Guardar como plantilla"
@@ -2016,11 +1996,6 @@
"advancedInputs": "ENTRADAS AVANZADAS",
"bypass": "Omitir",
"color": "Color del nodo",
"enterSubgraph": "Entrar en subgrafo",
"errorHelp": "Para más ayuda, {github} o {support}",
"errorHelpGithub": "envía un issue en GitHub",
"errorHelpSupport": "contacta con nuestro soporte",
"errors": "Errores",
"fallbackGroupTitle": "Grupo",
"fallbackNodeTitle": "Nodo",
"favorites": "ENTRADAS FAVORITAS",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "El nodo no tiene entradas",
"locateNode": "Localizar nodo en el lienzo",
"mute": "Silenciar",
"noErrors": "Sin errores",
"noSelection": "Selecciona un nodo para ver sus propiedades e información.",
"nodeState": "Estado del nodo",
"nodes": "Nodos",
@@ -2064,19 +2038,10 @@
"normal": "Normal",
"parameters": "Parámetros",
"pinned": "Fijado",
"promptErrors": {
"no_prompt": {
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
},
"prompt_no_outputs": {
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
}
},
"properties": "Propiedades",
"removeFavorite": "Quitar de favoritos",
"resetAllParameters": "Restablecer todos los parámetros",
"resetToDefault": "Restablecer a los valores predeterminados",
"seeError": "Ver error",
"settings": "Configuración",
"showAdvancedInputsButton": "Mostrar entradas avanzadas",
"showInput": "Mostrar entrada",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "Paletas de Colores Personalizadas",
"DevMode": "Modo de Desarrollo",
"EditTokenWeight": "Editar Peso del Token",
"Error System": "Sistema de errores",
"Execution": "Ejecución",
"Extension": "Extensión",
"General": "General",
@@ -2450,11 +2414,8 @@
"moreOptions": "Más opciones",
"noActiveJobs": "No hay trabajos activos",
"preview": "Vista previa",
"queuedJobsLabel": "{count} en cola",
"queuedSuffix": "en cola",
"running": "en ejecución",
"runningJobsLabel": "{count} en ejecución",
"runningQueuedSummary": "{running}, {queued}",
"showAssets": "Mostrar recursos",
"showAssetsPanel": "Mostrar panel de recursos",
"sortBy": "Ordenar por",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "Ecualizador de Audio (3 Bandas)",
"inputs": {
"audio": {
"name": "audio"
},
"high_freq": {
"name": "high_freq",
"tooltip": "Frecuencia de corte para el estante alto"
},
"high_gain_dB": {
"name": "high_gain_dB",
"tooltip": "Ganancia para frecuencias altas (Agudos)"
},
"low_freq": {
"name": "low_freq",
"tooltip": "Frecuencia de corte para el estante bajo"
},
"low_gain_dB": {
"name": "low_gain_dB",
"tooltip": "Ganancia para frecuencias bajas (Bajo)"
},
"mid_freq": {
"name": "mid_freq",
"tooltip": "Frecuencia central para medios"
},
"mid_gain_dB": {
"name": "mid_gain_dB",
"tooltip": "Ganancia para frecuencias medias"
},
"mid_q": {
"name": "mid_q",
"tooltip": "Factor Q (ancho de banda) para medios"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "Combina dos pistas de audio superponiendo sus formas de onda.",
"display_name": "Combinar Audio",
@@ -4283,26 +4242,6 @@
}
}
},
"ImageCropV2": {
"display_name": "Recorte de Imagen",
"inputs": {
"crop_region": {
"name": "crop_region"
},
"height": {},
"image": {
"name": "image"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "Eliminación de Imágenes Duplicadas",
"inputs": {
@@ -10399,32 +10338,6 @@
}
}
},
"NAGuidance": {
"description": "Aplica la Guía de Atención Normalizada a los modelos, permitiendo prompts negativos en modelos distilled/schnell.",
"display_name": "Guía de Atención Normalizada",
"inputs": {
"model": {
"name": "modelo",
"tooltip": "El modelo al que se aplicará NAG."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Factor de mezcla para la atención normalizada. 1.0 es reemplazo total, 0.0 sin efecto."
},
"nag_scale": {
"name": "escala_nag",
"tooltip": "El factor de escala de la guía. Valores más altos alejan más del prompt negativo."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "El modelo modificado con NAG habilitado."
}
}
},
"NormalizeImages": {
"display_name": "Normalizar Imágenes",
"inputs": {
@@ -11236,28 +11149,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "Caja Delimitadora",
"inputs": {
"height": {
"name": "height"
},
"width": {
"name": "width"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "Flotante",
"inputs": {
@@ -11821,88 +11712,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Genera imágenes usando los modelos Recraft V4 o V4 Pro.",
"display_name": "Recraft V4 Texto a Imagen",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación."
},
"model_size": {
"name": "tamaño"
},
"n": {
"name": "n",
"tooltip": "El número de imágenes a generar."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Genera SVG usando los modelos Recraft V4 o V4 Pro.",
"display_name": "Recraft V4 Texto a Vector",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación."
},
"model_size": {
"name": "tamaño"
},
"n": {
"name": "n",
"tooltip": "El número de imágenes a generar."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Genera SVG de forma sincrónica a partir de una imagen de entrada.",
"display_name": "Recraft Vectorizar Imagen",
@@ -16037,46 +15846,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Genera un video a partir de un fotograma inicial, un fotograma final y un prompt.",
"display_name": "Generación de video de inicio/fin de Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"end_frame": {
"name": "fotograma final"
},
"first_frame": {
"name": "fotograma inicial"
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la generación de video."
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "duración"
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción del prompt (máximo 2000 caracteres)."
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Genera un video a partir de un prompt de texto.",
"display_name": "Generación de video de texto a video Vidu Q3",

View File

@@ -350,10 +350,6 @@
"name": "Usar la cola de trabajos unificada en el panel lateral de Activos",
"tooltip": "Reemplaza el panel flotante de la cola de trabajos por una cola de trabajos equivalente integrada en el panel lateral de Activos. Puedes desactivar esto para volver al diseño del panel flotante."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Mostrar pestaña de errores en el panel lateral",
"tooltip": "Cuando está activado, se muestra una pestaña de errores en el panel lateral derecho para ver de un vistazo los errores de ejecución del flujo de trabajo."
},
"Comfy_Sidebar_Location": {
"name": "Ubicación de la barra lateral",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
"AUDIO_RECORD": "ضبط صوت",
"BOOLEAN": "بولی",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "کنترل دوربین",
"CLIP": "clip",
"CLIP_VISION": "بینایی clip",
@@ -1899,25 +1898,6 @@
"outputs": "خروجی‌ها",
"type": "نوع"
},
"nodeReplacement": {
"compatibleAlternatives": "گزینه‌های سازگار",
"installMissingNodes": "نصب نودهای مفقود",
"installationRequired": "نصب مورد نیاز است",
"instructionMessage": "برای اجرای workflow باید این نودها را نصب یا با گزینه‌های نصب‌شده جایگزین کنید. نودهای مفقود با رنگ {red} روی بوم مشخص شده‌اند. برخی نودها قابل تعویض نیستند و باید از طریق Node Manager نصب شوند.",
"notReplaceable": "نیاز به نصب",
"openNodeManager": "باز کردن Node Manager",
"quickFixAvailable": "رفع سریع در دسترس است",
"redHighlight": "قرمز",
"replaceFailed": "جایگزینی نودها ناموفق بود",
"replaceSelected": "جایگزینی انتخاب‌شده‌ها ({count})",
"replaceWarning": "این کار workflow را به طور دائمی تغییر می‌دهد. اگر مطمئن نیستید، ابتدا یک نسخه ذخیره کنید.",
"replaceable": "قابل جایگزینی",
"replaced": "جایگزین شد",
"replacedAllNodes": "{count} نوع نود جایگزین شد",
"replacedNode": "نود جایگزین شد: {nodeType}",
"selectAll": "انتخاب همه",
"skipForNow": "فعلاً رد شود"
},
"nodeTemplates": {
"enterName": "نام را وارد کنید",
"saveAsTemplate": "ذخیره به عنوان قالب"
@@ -2016,11 +1996,6 @@
"advancedInputs": "ورودی‌های پیشرفته",
"bypass": "عبور",
"color": "رنگ نود",
"enterSubgraph": "ورود به زیرگراف",
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
"errorHelpGithub": "ثبت یک issue در GitHub",
"errorHelpSupport": "تماس با پشتیبانی ما",
"errors": "خطاها",
"fallbackGroupTitle": "گروه",
"fallbackNodeTitle": "node",
"favorites": "ورودی‌های علاقه‌مندی",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "این نود ورودی ندارد",
"locateNode": "یافتن node در canvas",
"mute": "بی‌صدا",
"noErrors": "بدون خطا",
"noSelection": "یک نود را انتخاب کنید تا ویژگی‌ها و اطلاعات آن نمایش داده شود.",
"nodeState": "وضعیت نود",
"nodes": "nodeها",
@@ -2064,19 +2038,10 @@
"normal": "عادی",
"parameters": "پارامترها",
"pinned": "سنجاق شده",
"promptErrors": {
"no_prompt": {
"desc": "داده‌های گردش‌کار ارسال‌شده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
},
"prompt_no_outputs": {
"desc": "گردش‌کار هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه ندارد."
}
},
"properties": "ویژگی‌ها",
"removeFavorite": "حذف از علاقه‌مندی‌ها",
"resetAllParameters": "بازنشانی همه پارامترها",
"resetToDefault": "بازنشانی به پیش‌فرض",
"seeError": "مشاهده خطا",
"settings": "تنظیمات",
"showAdvancedInputsButton": "نمایش ورودی‌های پیشرفته",
"showInput": "نمایش ورودی",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "پالت‌های رنگ سفارشی",
"DevMode": "حالت توسعه‌دهنده",
"EditTokenWeight": "ویرایش وزن توکن",
"Error System": "سیستم خطا",
"Execution": "اجرا",
"Extension": "افزونه",
"General": "عمومی",
@@ -2461,11 +2425,8 @@
"moreOptions": "گزینه‌های بیشتر",
"noActiveJobs": "کار فعالی وجود ندارد",
"preview": "پیش‌نمایش",
"queuedJobsLabel": "{count} در صف",
"queuedSuffix": "در صف",
"running": "در حال اجرا",
"runningJobsLabel": "{count} در حال اجرا",
"runningQueuedSummary": "{running}، {queued}",
"showAssets": "نمایش دارایی‌ها",
"showAssetsPanel": "نمایش پنل دارایی‌ها",
"sortBy": "مرتب‌سازی بر اساس",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "اکولایزر صوتی (۳-باند)",
"inputs": {
"audio": {
"name": "audio"
},
"high_freq": {
"name": "high_freq",
"tooltip": "فرکانس قطع برای شلف بالا"
},
"high_gain_dB": {
"name": "high_gain_dB",
"tooltip": "افزایش برای فرکانس‌های بالا (تریبل)"
},
"low_freq": {
"name": "low_freq",
"tooltip": "فرکانس قطع برای شلف پایین"
},
"low_gain_dB": {
"name": "low_gain_dB",
"tooltip": "افزایش برای فرکانس‌های پایین (بیس)"
},
"mid_freq": {
"name": "mid_freq",
"tooltip": "فرکانس مرکزی برای میانی"
},
"mid_gain_dB": {
"name": "mid_gain_dB",
"tooltip": "افزایش برای فرکانس‌های میانی"
},
"mid_q": {
"name": "mid_q",
"tooltip": "ضریب Q (پهنای باند) برای میانی"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "دو ترک صوتی را با هم ترکیب می‌کند و موج‌های صوتی آن‌ها را روی هم قرار می‌دهد.",
"display_name": "Audio Merge",
@@ -4292,26 +4251,6 @@
}
}
},
"ImageCropV2": {
"display_name": "برش تصویر",
"inputs": {
"crop_region": {
"name": "crop_region"
},
"height": {},
"image": {
"name": "image"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "حذف تصاویر تکراری",
"inputs": {
@@ -10401,32 +10340,6 @@
}
}
},
"NAGuidance": {
"description": "راهنمای توجه نرمال‌سازی‌شده را به مدل‌ها اعمال می‌کند و امکان استفاده از پرامپت منفی را در مدل‌های distilled/schnell فراهم می‌سازد.",
"display_name": "راهنمای توجه نرمال‌سازی‌شده",
"inputs": {
"model": {
"name": "مدل",
"tooltip": "مدلی که NAG بر روی آن اعمال می‌شود."
},
"nag_alpha": {
"name": "ضریب ترکیب",
"tooltip": "ضریب ترکیب برای توجه نرمال‌سازی‌شده. مقدار ۱.۰ به معنای جایگزینی کامل و ۰.۰ بدون تأثیر است."
},
"nag_scale": {
"name": "مقیاس راهنما",
"tooltip": "ضریب مقیاس راهنما. مقادیر بالاتر فاصله بیشتری از پرامپت منفی ایجاد می‌کند."
},
"nag_tau": {
"name": "تاو"
}
},
"outputs": {
"0": {
"tooltip": "مدل اصلاح‌شده با فعال‌سازی NAG."
}
}
},
"NormalizeImages": {
"display_name": "نرمال‌سازی تصاویر",
"inputs": {
@@ -11238,28 +11151,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "جعبه مرزی",
"inputs": {
"height": {
"name": "height"
},
"width": {
"name": "width"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "عدد اعشاری",
"inputs": {
@@ -11823,88 +11714,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "تولید تصویر با استفاده از مدل‌های Recraft V4 یا V4 Pro.",
"display_name": "تبدیل متن به تصویر Recraft V4",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدل مورد استفاده برای تولید."
},
"model_size": {
"name": "اندازه"
},
"n": {
"name": "تعداد",
"tooltip": "تعداد تصاویر تولیدی."
},
"negative_prompt": {
"name": "پرامپت منفی",
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
},
"prompt": {
"name": "پرامپت",
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
},
"recraft_controls": {
"name": "کنترل‌های Recraft",
"tooltip": "کنترل‌های اختیاری بیشتر بر تولید از طریق node کنترل‌های Recraft."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "تولید SVG با استفاده از مدل‌های Recraft V4 یا V4 Pro.",
"display_name": "تبدیل متن به وکتور Recraft V4",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدل مورد استفاده برای تولید."
},
"model_size": {
"name": "اندازه"
},
"n": {
"name": "تعداد",
"tooltip": "تعداد تصاویر تولیدی."
},
"negative_prompt": {
"name": "پرامپت منفی",
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
},
"prompt": {
"name": "پرامپت",
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
},
"recraft_controls": {
"name": "کنترل‌های Recraft",
"tooltip": "کنترل‌های اختیاری بیشتر بر تولید از طریق node کنترل‌های Recraft."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "تولید SVG به صورت همزمان از یک تصویر ورودی.",
"display_name": "وکتورسازی تصویر Recraft",
@@ -16050,46 +15859,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "تولید یک ویدیو از یک فریم آغازین، یک فریم پایانی و یک پرامپت.",
"display_name": "تولید ویدیو از فریم آغازین/پایانی Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"end_frame": {
"name": "فریم پایانی"
},
"first_frame": {
"name": "فریم آغازین"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای تولید ویدیو استفاده می‌شود."
},
"model_audio": {
"name": "صدا"
},
"model_duration": {
"name": "مدت زمان"
},
"model_resolution": {
"name": "وضوح"
},
"prompt": {
"name": "پرامپت",
"tooltip": "توضیح پرامپت (حداکثر ۲۰۰۰ کاراکتر)."
},
"seed": {
"name": "بذر"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "تولید ویدیو از یک پرامپت متنی.",
"display_name": "تولید ویدیو از متن Vidu Q3",

View File

@@ -350,10 +350,6 @@
"name": "استفاده از صف کار یکپارچه در پنل کناری دارایی‌ها",
"tooltip": "پنل شناور صف کار را با صف کاری معادل که در پنل کناری دارایی‌ها قرار دارد جایگزین می‌کند. می‌توانید این گزینه را غیرفعال کنید تا به چیدمان پنل شناور بازگردید."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "نمایش زبانه خطاها در پنل کناری",
"tooltip": "در صورت فعال بودن، زبانه‌ای برای نمایش خطاهای اجرای workflow در پنل سمت راست نمایش داده می‌شود تا بتوانید خطاها را به‌صورت یکجا مشاهده کنید."
},
"Comfy_Sidebar_Location": {
"name": "محل نوار کناری",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "SORTIE_ENCODEUR_AUDIO",
"AUDIO_RECORD": "ENREGISTREMENT_AUDIO",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOÎTE ENGLOBANTE",
"CAMERA_CONTROL": "Contrôle de la caméra",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -1899,25 +1898,6 @@
"outputs": "Sorties",
"type": "Type"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternatives compatibles",
"installMissingNodes": "Installer les nœuds manquants",
"installationRequired": "Installation requise",
"instructionMessage": "Vous devez installer ces nœuds ou les remplacer par des alternatives installées pour exécuter le workflow. Les nœuds manquants sont surlignés en {red} sur le canevas. Certains nœuds ne peuvent pas être remplacés et doivent être installés via le Gestionnaire de nœuds.",
"notReplaceable": "Installation requise",
"openNodeManager": "Ouvrir le Gestionnaire de nœuds",
"quickFixAvailable": "Correction rapide disponible",
"redHighlight": "rouge",
"replaceFailed": "Échec du remplacement des nœuds",
"replaceSelected": "Remplacer la sélection ({count})",
"replaceWarning": "Cela modifiera définitivement le workflow. Sauvegardez une copie si vous nêtes pas sûr.",
"replaceable": "Remplaçable",
"replaced": "Remplacé",
"replacedAllNodes": "{count} type(s) de nœud remplacé(s)",
"replacedNode": "Nœud remplacé : {nodeType}",
"selectAll": "Tout sélectionner",
"skipForNow": "Ignorer pour linstant"
},
"nodeTemplates": {
"enterName": "Entrez le nom",
"saveAsTemplate": "Enregistrer comme modèle"
@@ -2016,11 +1996,6 @@
"advancedInputs": "ENTRÉES AVANCÉES",
"bypass": "Contourner",
"color": "Couleur du nœud",
"enterSubgraph": "Entrer dans le sous-graphe",
"errorHelp": "Pour plus d'aide, {github} ou {support}",
"errorHelpGithub": "soumettre un ticket GitHub",
"errorHelpSupport": "contacter notre support",
"errors": "Erreurs",
"fallbackGroupTitle": "Groupe",
"fallbackNodeTitle": "Nœud",
"favorites": "ENTRÉES FAVORITES",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "Le nœud na pas dentrées",
"locateNode": "Localiser le nœud sur le canevas",
"mute": "Muet",
"noErrors": "Aucune erreur",
"noSelection": "Sélectionnez un nœud pour voir ses propriétés et informations.",
"nodeState": "État du nœud",
"nodes": "Nœuds",
@@ -2064,19 +2038,10 @@
"normal": "Normal",
"parameters": "Paramètres",
"pinned": "Épinglé",
"promptErrors": {
"no_prompt": {
"desc": "Les données du flux de travail envoyées au serveur sont vides. Il peut s'agir d'une erreur système inattendue."
},
"prompt_no_outputs": {
"desc": "Le flux de travail ne contient aucun nœud de sortie (par exemple, Enregistrer l'image, Prévisualiser l'image) pour produire un résultat."
}
},
"properties": "Propriétés",
"removeFavorite": "Retirer des favoris",
"resetAllParameters": "Réinitialiser tous les paramètres",
"resetToDefault": "Réinitialiser par défaut",
"seeError": "Voir l'erreur",
"settings": "Paramètres",
"showAdvancedInputsButton": "Afficher les entrées avancées",
"showInput": "Afficher lentrée",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "Palettes de Couleurs Personnalisées",
"DevMode": "Mode Développeur",
"EditTokenWeight": "Modifier le Poids du Jeton",
"Error System": "Système d'erreurs",
"Execution": "Exécution",
"Extension": "Extension",
"General": "Général",
@@ -2450,11 +2414,8 @@
"moreOptions": "Plus doptions",
"noActiveJobs": "Aucun travail actif",
"preview": "Aperçu",
"queuedJobsLabel": "{count} en file dattente",
"queuedSuffix": "en file dattente",
"running": "en cours",
"runningJobsLabel": "{count} en cours",
"runningQueuedSummary": "{running} en cours, {queued} en file",
"showAssets": "Afficher les ressources",
"showAssetsPanel": "Afficher le panneau des ressources",
"sortBy": "Trier par",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "Égaliseur audio (3 bandes)",
"inputs": {
"audio": {
"name": "audio"
},
"high_freq": {
"name": "high_freq",
"tooltip": "Fréquence de coupure pour l'étagère haute"
},
"high_gain_dB": {
"name": "high_gain_dB",
"tooltip": "Gain pour les hautes fréquences (aigus)"
},
"low_freq": {
"name": "low_freq",
"tooltip": "Fréquence de coupure pour l'étagère basse"
},
"low_gain_dB": {
"name": "low_gain_dB",
"tooltip": "Gain pour les basses fréquences (basses)"
},
"mid_freq": {
"name": "mid_freq",
"tooltip": "Fréquence centrale pour les médiums"
},
"mid_gain_dB": {
"name": "mid_gain_dB",
"tooltip": "Gain pour les fréquences moyennes"
},
"mid_q": {
"name": "mid_q",
"tooltip": "Facteur Q (bande passante) pour les médiums"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "Combine deux pistes audio en superposant leurs formes d'onde.",
"display_name": "Fusion Audio",
@@ -4283,26 +4242,6 @@
}
}
},
"ImageCropV2": {
"display_name": "Rogner l'image",
"inputs": {
"crop_region": {
"name": "crop_region"
},
"height": {},
"image": {
"name": "image"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "Déduplication d'image",
"inputs": {
@@ -10399,32 +10338,6 @@
}
}
},
"NAGuidance": {
"description": "Applique le Guidage dAttention Normalisée aux modèles, permettant lutilisation de prompts négatifs sur les modèles distilled/schnell.",
"display_name": "Guidage dAttention Normalisée",
"inputs": {
"model": {
"name": "modèle",
"tooltip": "Le modèle auquel appliquer NAG."
},
"nag_alpha": {
"name": "alpha_nag",
"tooltip": "Facteur de fusion pour lattention normalisée. 1,0 correspond à un remplacement total, 0,0 à aucun effet."
},
"nag_scale": {
"name": "facteur_nag",
"tooltip": "Le facteur déchelle du guidage. Des valeurs plus élevées éloignent davantage du prompt négatif."
},
"nag_tau": {
"name": "tau_nag"
}
},
"outputs": {
"0": {
"tooltip": "Le modèle modifié avec NAG activé."
}
}
},
"NormalizeImages": {
"display_name": "Normaliser les images",
"inputs": {
@@ -11236,28 +11149,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "Boîte englobante",
"inputs": {
"height": {
"name": "height"
},
"width": {
"name": "width"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "Flottant",
"inputs": {
@@ -11821,88 +11712,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Génère des images à laide des modèles Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texte vers Image",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour la génération."
},
"model_size": {
"name": "taille"
},
"n": {
"name": "n",
"tooltip": "Nombre dimages à générer."
},
"negative_prompt": {
"name": "prompt_négatif",
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt pour la génération dimage. Maximum 10 000 caractères."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Génère des SVG à laide des modèles Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texte vers Vectoriel",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle",
"tooltip": "Le modèle à utiliser pour la génération."
},
"model_size": {
"name": "taille"
},
"n": {
"name": "n",
"tooltip": "Nombre dimages à générer."
},
"negative_prompt": {
"name": "prompt_négatif",
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt pour la génération dimage. Maximum 10 000 caractères."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Génère un SVG de manière synchrone à partir d'une image d'entrée.",
"display_name": "Vectoriser une image avec Recraft",
@@ -16037,46 +15846,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Générez une vidéo à partir d'une image de début, d'une image de fin et d'une invite.",
"display_name": "Génération vidéo Vidu Q3 à partir d'une image de début/fin",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"end_frame": {
"name": "image de fin"
},
"first_frame": {
"name": "image de début"
},
"model": {
"name": "modèle",
"tooltip": "Modèle à utiliser pour la génération vidéo."
},
"model_audio": {
"name": "audio"
},
"model_duration": {
"name": "durée"
},
"model_resolution": {
"name": "résolution"
},
"prompt": {
"name": "invite",
"tooltip": "Description de l'invite (2000 caractères max)."
},
"seed": {
"name": "graine"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Générez une vidéo à partir dune invite textuelle.",
"display_name": "Génération de vidéo à partir de texte Vidu Q3",

View File

@@ -350,10 +350,6 @@
"name": "Utiliser la file dattente unifiée dans le panneau latéral des ressources",
"tooltip": "Remplace le panneau flottant de la file dattente des tâches par une file dattente équivalente intégrée dans le panneau latéral des ressources. Vous pouvez désactiver cette option pour revenir à la disposition du panneau flottant."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Afficher longlet des erreurs dans le panneau latéral",
"tooltip": "Lorsque cette option est activée, un onglet des erreurs saffiche dans le panneau latéral droit pour visualiser rapidement les erreurs dexécution du workflow."
},
"Comfy_Sidebar_Location": {
"name": "Emplacement de la barre latérale",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "オーディオエンコーダ出力",
"AUDIO_RECORD": "オーディオ録音",
"BOOLEAN": "ブール",
"BOUNDING_BOX": "バウンディングボックス",
"CAMERA_CONTROL": "カメラコントロール",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -1899,25 +1898,6 @@
"outputs": "出力",
"type": "タイプ"
},
"nodeReplacement": {
"compatibleAlternatives": "互換性のある代替案",
"installMissingNodes": "不足ノードをインストール",
"installationRequired": "インストールが必要",
"instructionMessage": "ワークフローを実行するには、これらのノードをインストールするか、インストール済みの代替ノードに置き換える必要があります。足りないノードはキャンバス上で{red}でハイライトされています。一部のードは置き換えできず、Node Managerからインストールする必要があります。",
"notReplaceable": "インストールが必要",
"openNodeManager": "Node Managerを開く",
"quickFixAvailable": "クイック修正可能",
"redHighlight": "赤",
"replaceFailed": "ノードの置き換えに失敗しました",
"replaceSelected": "選択したものを置き換え ({count})",
"replaceWarning": "この操作はワークフローを永久に変更します。心配な場合は、先にコピーを保存してください。",
"replaceable": "置き換え可能",
"replaced": "置き換え済み",
"replacedAllNodes": "{count} 種類のノードを置き換えました",
"replacedNode": "置き換えたノード: {nodeType}",
"selectAll": "すべて選択",
"skipForNow": "今はスキップ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -2016,11 +1996,6 @@
"advancedInputs": "詳細入力",
"bypass": "バイパス",
"color": "ノードカラー",
"enterSubgraph": "サブグラフに入る",
"errorHelp": "詳細なヘルプについては、{github} または {support} をご利用ください",
"errorHelpGithub": "GitHub イシューを提出",
"errorHelpSupport": "サポートに連絡",
"errors": "エラー",
"fallbackGroupTitle": "グループ",
"fallbackNodeTitle": "ノード",
"favorites": "お気に入り入力",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "このノードには入力がありません",
"locateNode": "キャンバス上でノードを探す",
"mute": "ミュート",
"noErrors": "エラーなし",
"noSelection": "ノードを選択すると、そのプロパティと情報が表示されます。",
"nodeState": "ノードの状態",
"nodes": "ノード",
@@ -2064,19 +2038,10 @@
"normal": "ノーマル",
"parameters": "パラメータ",
"pinned": "ピン留め",
"promptErrors": {
"no_prompt": {
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
},
"prompt_no_outputs": {
"desc": "ワークフローに結果を生成する出力ノード(例:画像を保存、画像をプレビュー)が含まれていません。"
}
},
"properties": "プロパティ",
"removeFavorite": "お気に入りを解除",
"resetAllParameters": "すべてのパラメータをリセット",
"resetToDefault": "デフォルトにリセット",
"seeError": "エラーを見る",
"settings": "設定",
"showAdvancedInputsButton": "詳細入力を表示",
"showInput": "入力を表示",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "カスタムカラーパレット",
"DevMode": "開発モード",
"EditTokenWeight": "トークンの重みを編集",
"Error System": "エラーシステム",
"Execution": "実行",
"Extension": "拡張",
"General": "一般",
@@ -2450,11 +2414,8 @@
"moreOptions": "その他のオプション",
"noActiveJobs": "アクティブなジョブはありません",
"preview": "プレビュー",
"queuedJobsLabel": "{count} キュー中",
"queuedSuffix": "キュー済み",
"running": "実行中",
"runningJobsLabel": "{count} 実行中",
"runningQueuedSummary": "{running} 実行中、{queued} キュー中",
"showAssets": "アセットを表示",
"showAssetsPanel": "アセットパネルを表示",
"sortBy": "並べ替え条件",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "オーディオイコライザー3バンド",
"inputs": {
"audio": {
"name": "audio"
},
"high_freq": {
"name": "high_freq",
"tooltip": "高域シェルフのカットオフ周波数"
},
"high_gain_dB": {
"name": "high_gain_dB",
"tooltip": "高域(トレブル)のゲイン"
},
"low_freq": {
"name": "low_freq",
"tooltip": "低域シェルフのカットオフ周波数"
},
"low_gain_dB": {
"name": "low_gain_dB",
"tooltip": "低域(ベース)のゲイン"
},
"mid_freq": {
"name": "mid_freq",
"tooltip": "中域の中心周波数"
},
"mid_gain_dB": {
"name": "mid_gain_dB",
"tooltip": "中域のゲイン"
},
"mid_q": {
"name": "mid_q",
"tooltip": "中域のQファクター帯域幅"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "2つのオーディオトラックを波形を重ねて結合します。",
"display_name": "オーディオ結合",
@@ -4283,26 +4242,6 @@
}
}
},
"ImageCropV2": {
"display_name": "画像切り抜き",
"inputs": {
"crop_region": {
"name": "crop_region"
},
"height": {},
"image": {
"name": "image"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "画像の重複排除",
"inputs": {
@@ -10399,32 +10338,6 @@
}
}
},
"NAGuidance": {
"description": "モデルに正規化アテンションガイダンスを適用し、distilled/schnellモデルでネガティブプロンプトを有効にします。",
"display_name": "正規化アテンションガイダンス",
"inputs": {
"model": {
"name": "model",
"tooltip": "NAGを適用するモデル。"
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "正規化アテンションのブレンド係数。1.0は完全な置換、0.0は効果なし。"
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "ガイダンスのスケール係数。値が高いほどネガティブプロンプトからさらに離れます。"
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "NAGが有効化されたパッチ済みモデル。"
}
}
},
"NormalizeImages": {
"display_name": "画像を正規化",
"inputs": {
@@ -11236,28 +11149,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "バウンディングボックス",
"inputs": {
"height": {
"name": "height"
},
"width": {
"name": "width"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "浮動小数点数",
"inputs": {
@@ -11821,88 +11712,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Recraft V4またはV4 Proモデルを使用して画像を生成します。",
"display_name": "Recraft V4 テキストから画像生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "model",
"tooltip": "生成に使用するモデル。"
},
"model_size": {
"name": "サイズ"
},
"n": {
"name": "n",
"tooltip": "生成する画像の枚数。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
},
"prompt": {
"name": "prompt",
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controlsードによる追加の生成コントロール任意。"
},
"seed": {
"name": "seed",
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Recraft V4またはV4 Proモデルを使用してSVGを生成します。",
"display_name": "Recraft V4 テキストからベクター生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"model": {
"name": "model",
"tooltip": "生成に使用するモデル。"
},
"model_size": {
"name": "サイズ"
},
"n": {
"name": "n",
"tooltip": "生成する画像の枚数。"
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
},
"prompt": {
"name": "prompt",
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controlsードによる追加の生成コントロール任意。"
},
"seed": {
"name": "seed",
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "入力画像からSVGを同期的に生成します。",
"display_name": "Recraft ベクトル化画像",
@@ -16037,46 +15846,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "開始フレーム、終了フレーム、およびプロンプトから動画を生成します。",
"display_name": "Vidu Q3 開始/終了フレームからの動画生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"end_frame": {
"name": "終了フレーム"
},
"first_frame": {
"name": "開始フレーム"
},
"model": {
"name": "モデル",
"tooltip": "動画生成に使用するモデル。"
},
"model_audio": {
"name": "オーディオ"
},
"model_duration": {
"name": "再生時間"
},
"model_resolution": {
"name": "解像度"
},
"prompt": {
"name": "プロンプト",
"tooltip": "プロンプトの説明最大2000文字。"
},
"seed": {
"name": "シード"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "テキストプロンプトから動画を生成します。",
"display_name": "Vidu Q3 テキストから動画生成",

View File

@@ -350,10 +350,6 @@
"name": "アセットサイドパネルで統一ジョブキューを使用",
"tooltip": "フローティングジョブキューパネルを、アセットサイドパネルに埋め込まれた同等のジョブキューに置き換えます。無効にすると、フローティングパネルのレイアウトに戻ります。"
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "サイドパネルにエラータブを表示",
"tooltip": "有効にすると、右側のサイドパネルにエラータブが表示され、ワークフロー実行時のエラーを一目で確認できます。"
},
"Comfy_Sidebar_Location": {
"name": "サイドバーの位置",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
"BOOLEAN": "논리값",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "카메라 제어",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -1899,25 +1898,6 @@
"outputs": "출력",
"type": "유형"
},
"nodeReplacement": {
"compatibleAlternatives": "호환 가능한 대안",
"installMissingNodes": "누락된 노드 설치",
"installationRequired": "설치 필요",
"instructionMessage": "워크플로를 실행하려면 이 노드를 설치하거나 설치된 대안으로 교체해야 합니다. 누락된 노드는 캔버스에서 {red}로 강조 표시됩니다. 일부 노드는 교체할 수 없으므로 Node Manager를 통해 설치해야 합니다.",
"notReplaceable": "설치 필요",
"openNodeManager": "Node Manager 열기",
"quickFixAvailable": "빠른 수정 가능",
"redHighlight": "빨간색",
"replaceFailed": "노드 교체 실패",
"replaceSelected": "선택한 항목 교체 ({count})",
"replaceWarning": "이 작업은 워크플로를 영구적으로 수정합니다. 확실하지 않으면 먼저 복사본을 저장하세요.",
"replaceable": "교체 가능",
"replaced": "교체됨",
"replacedAllNodes": "{count}개 노드 유형 교체됨",
"replacedNode": "교체된 노드: {nodeType}",
"selectAll": "전체 선택",
"skipForNow": "일단 건너뛰기"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -2016,11 +1996,6 @@
"advancedInputs": "고급 입력",
"bypass": "우회",
"color": "노드 색상",
"enterSubgraph": "서브그래프 진입",
"errorHelp": "더 많은 도움이 필요하시면 {github} 또는 {support}를 이용하세요.",
"errorHelpGithub": "GitHub 이슈 제출",
"errorHelpSupport": "고객 지원팀에 문의",
"errors": "오류",
"fallbackGroupTitle": "그룹",
"fallbackNodeTitle": "노드",
"favorites": "즐겨찾는 입력",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "노드에 입력이 없습니다",
"locateNode": "캔버스에서 노드 찾기",
"mute": "음소거",
"noErrors": "오류 없음",
"noSelection": "노드를 선택하면 속성과 정보를 볼 수 있습니다.",
"nodeState": "노드 상태",
"nodes": "노드",
@@ -2064,19 +2038,10 @@
"normal": "일반",
"parameters": "파라미터",
"pinned": "고정됨",
"promptErrors": {
"no_prompt": {
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 이는 예기치 않은 시스템 오류일 수 있습니다."
},
"prompt_no_outputs": {
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 포함되어 있지 않습니다."
}
},
"properties": "속성",
"removeFavorite": "즐겨찾기 해제",
"resetAllParameters": "모든 매개변수 재설정",
"resetToDefault": "기본값으로 재설정",
"seeError": "오류 보기",
"settings": "설정",
"showAdvancedInputsButton": "고급 입력 표시",
"showInput": "입력 표시",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "사용자 정의 색상 팔레트",
"DevMode": "개발자 모드",
"EditTokenWeight": "토큰 가중치 편집",
"Error System": "오류 시스템",
"Execution": "실행",
"Extension": "확장",
"General": "일반",
@@ -2450,11 +2414,8 @@
"moreOptions": "더 많은 옵션",
"noActiveJobs": "활성 작업 없음",
"preview": "미리보기",
"queuedJobsLabel": "{count}개 대기 중",
"queuedSuffix": "대기 중",
"running": "실행 중",
"runningJobsLabel": "{count}개 실행 중",
"runningQueuedSummary": "{running} 실행 중, {queued} 대기 중",
"showAssets": "에셋 보기",
"showAssetsPanel": "에셋 패널 보기",
"sortBy": "정렬 기준",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "오디오 이퀄라이저 (3-밴드)",
"inputs": {
"audio": {
"name": "오디오"
},
"high_freq": {
"name": "고역 컷오프 주파수",
"tooltip": "고역 셸프의 컷오프 주파수"
},
"high_gain_dB": {
"name": "고역 게인 (dB)",
"tooltip": "고주파수 (트레블) 게인"
},
"low_freq": {
"name": "저역 컷오프 주파수",
"tooltip": "저역 셸프의 컷오프 주파수"
},
"low_gain_dB": {
"name": "저역 게인 (dB)",
"tooltip": "저주파수 (베이스) 게인"
},
"mid_freq": {
"name": "중역 중심 주파수",
"tooltip": "중역의 중심 주파수"
},
"mid_gain_dB": {
"name": "중역 게인 (dB)",
"tooltip": "중주파수 게인"
},
"mid_q": {
"name": "중역 Q",
"tooltip": "중역의 Q 팩터 (대역폭)"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "두 오디오 트랙의 파형을 겹쳐서 결합합니다.",
"display_name": "오디오 병합",
@@ -4283,26 +4242,6 @@
}
}
},
"ImageCropV2": {
"display_name": "이미지 자르기",
"inputs": {
"crop_region": {
"name": "자르기 영역"
},
"height": {},
"image": {
"name": "이미지"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "이미지 중복 제거",
"inputs": {
@@ -10399,32 +10338,6 @@
}
}
},
"NAGuidance": {
"description": "정규화된 어텐션 가이던스를 모델에 적용하여, distilled/schnell 모델에서 네거티브 프롬프트를 사용할 수 있게 합니다.",
"display_name": "정규화된 어텐션 가이던스",
"inputs": {
"model": {
"name": "model",
"tooltip": "NAG를 적용할 모델입니다."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "정규화된 어텐션의 블렌딩 계수입니다. 1.0은 완전 대체, 0.0은 효과 없음입니다."
},
"nag_scale": {
"name": "nag_scale",
"tooltip": "가이던스 스케일 계수입니다. 값이 높을수록 네거티브 프롬프트에서 더 멀어집니다."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "NAG가 활성화된 패치된 모델입니다."
}
}
},
"NormalizeImages": {
"display_name": "이미지 정규화",
"inputs": {
@@ -11236,28 +11149,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "바운딩 박스",
"inputs": {
"height": {
"name": "높이"
},
"width": {
"name": "너비"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "실수",
"inputs": {
@@ -11821,88 +11712,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 이미지를 생성합니다.",
"display_name": "Recraft V4 텍스트-이미지",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "생성에 사용할 모델입니다."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "생성할 이미지의 개수입니다."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
},
"prompt": {
"name": "prompt",
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
},
"seed": {
"name": "seed",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 SVG를 생성합니다.",
"display_name": "Recraft V4 텍스트-벡터",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"model": {
"name": "model",
"tooltip": "생성에 사용할 모델입니다."
},
"model_size": {
"name": "size"
},
"n": {
"name": "n",
"tooltip": "생성할 이미지의 개수입니다."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
},
"prompt": {
"name": "prompt",
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
},
"seed": {
"name": "seed",
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "입력 이미지로부터 SVG를 동기적으로 생성합니다.",
"display_name": "Recraft 벡터 생성 (이미지 → 벡터)",
@@ -16037,46 +15846,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "시작 프레임, 종료 프레임, 프롬프트를 사용하여 비디오를 생성합니다.",
"display_name": "Vidu Q3 시작/종료 프레임-투-비디오 생성",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"end_frame": {
"name": "종료 프레임"
},
"first_frame": {
"name": "시작 프레임"
},
"model": {
"name": "모델",
"tooltip": "비디오 생성을 위해 사용할 모델입니다."
},
"model_audio": {
"name": "오디오"
},
"model_duration": {
"name": "길이"
},
"model_resolution": {
"name": "해상도"
},
"prompt": {
"name": "프롬프트",
"tooltip": "프롬프트 설명 (최대 2000자)."
},
"seed": {
"name": "시드"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "텍스트 프롬프트로부터 비디오를 생성합니다.",
"display_name": "Vidu Q3 텍스트-비디오 생성",

View File

@@ -249,8 +249,8 @@
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeReplacement_Enabled": {
"name": "노드 교체 제안 활성화",
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
"name": "자동 노드 교체 활성화",
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",
@@ -350,10 +350,6 @@
"name": "에셋 사이드 패널에서 통합 작업 큐 사용",
"tooltip": "떠다니는 작업 큐 패널을 에셋 사이드 패널에 내장된 동등한 작업 큐로 대체합니다. 이 옵션을 비활성화하면 기존의 떠다니는 패널 레이아웃으로 돌아갈 수 있습니다."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "오류 탭을 사이드 패널에 표시",
"tooltip": "활성화하면 오른쪽 사이드 패널에 오류 탭이 표시되어 워크플로 실행 오류를 한눈에 확인할 수 있습니다."
},
"Comfy_Sidebar_Location": {
"name": "사이드바 위치",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "SAÍDA DO CODIFICADOR DE ÁUDIO",
"AUDIO_RECORD": "GRAVAÇÃO DE ÁUDIO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CONTROLE DE CÂMERA",
"CLIP": "clip",
"CLIP_VISION": "clip visão",
@@ -1899,25 +1898,6 @@
"outputs": "Saídas",
"type": "Tipo"
},
"nodeReplacement": {
"compatibleAlternatives": "Alternativas Compatíveis",
"installMissingNodes": "Instalar Nós Ausentes",
"installationRequired": "Instalação Necessária",
"instructionMessage": "Você deve instalar esses nós ou substituí-los por alternativas já instaladas para executar o fluxo de trabalho. Nós ausentes estão destacados em {red} na tela. Alguns nós não podem ser trocados e devem ser instalados pelo Gerenciador de Nós.",
"notReplaceable": "Instalação Necessária",
"openNodeManager": "Abrir Gerenciador de Nós",
"quickFixAvailable": "Correção Rápida Disponível",
"redHighlight": "vermelho",
"replaceFailed": "Falha ao substituir nós",
"replaceSelected": "Substituir Selecionados ({count})",
"replaceWarning": "Isso modificará permanentemente o fluxo de trabalho. Salve uma cópia antes se não tiver certeza.",
"replaceable": "Substituível",
"replaced": "Substituído",
"replacedAllNodes": "Substituídos {count} tipo(s) de nó",
"replacedNode": "Nó substituído: {nodeType}",
"selectAll": "Selecionar Tudo",
"skipForNow": "Pular por enquanto"
},
"nodeTemplates": {
"enterName": "Digite o nome",
"saveAsTemplate": "Salvar como modelo"
@@ -2016,11 +1996,6 @@
"advancedInputs": "ENTRADAS AVANÇADAS",
"bypass": "Ignorar",
"color": "Cor do nó",
"enterSubgraph": "Entrar no subgrafo",
"errorHelp": "Para mais ajuda, {github} ou {support}",
"errorHelpGithub": "enviar um issue no GitHub",
"errorHelpSupport": "contatar nosso suporte",
"errors": "Erros",
"fallbackGroupTitle": "Grupo",
"fallbackNodeTitle": "Nó",
"favorites": "ENTRADAS FAVORITAS",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "O nó não possui entradas",
"locateNode": "Localizar nó no canvas",
"mute": "Silenciar",
"noErrors": "Sem erros",
"noSelection": "Selecione um nó para ver suas propriedades e informações.",
"nodeState": "Estado do nó",
"nodes": "Nós",
@@ -2064,19 +2038,10 @@
"normal": "Normal",
"parameters": "Parâmetros",
"pinned": "Fixado",
"promptErrors": {
"no_prompt": {
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro de sistema inesperado."
},
"prompt_no_outputs": {
"desc": "O fluxo de trabalho não contém nenhum nó de saída (por exemplo, Salvar Imagem, Visualizar Imagem) para produzir um resultado."
}
},
"properties": "Propriedades",
"removeFavorite": "Desfavoritar",
"resetAllParameters": "Redefinir todos os parâmetros",
"resetToDefault": "Restaurar para o padrão",
"seeError": "Ver erro",
"settings": "Configurações",
"showAdvancedInputsButton": "Mostrar entradas avançadas",
"showInput": "Mostrar entrada",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "Paletas de Cores Personalizadas",
"DevMode": "Modo Desenvolvedor",
"EditTokenWeight": "Editar Peso do Token",
"Error System": "Sistema de Erros",
"Execution": "Execução",
"Extension": "Extensão",
"General": "Geral",
@@ -2461,11 +2425,8 @@
"moreOptions": "Mais opções",
"noActiveJobs": "Nenhum trabalho ativo",
"preview": "Pré-visualização",
"queuedJobsLabel": "{count} na fila",
"queuedSuffix": "na fila",
"running": "executando",
"runningJobsLabel": "{count} em execução",
"runningQueuedSummary": "{running} em execução, {queued} na fila",
"showAssets": "Mostrar ativos",
"showAssetsPanel": "Mostrar painel de ativos",
"sortBy": "Ordenar por",

View File

@@ -208,47 +208,6 @@
}
}
},
"AudioEqualizer3Band": {
"display_name": "Equalizador de Áudio (3 Bandas)",
"inputs": {
"audio": {
"name": "áudio"
},
"high_freq": {
"name": "freq_aguda",
"tooltip": "Frequência de corte para o filtro de agudos"
},
"high_gain_dB": {
"name": "ganho_agudo_dB",
"tooltip": "Ganho para frequências altas (Agudo)"
},
"low_freq": {
"name": "freq_baixa",
"tooltip": "Frequência de corte para o filtro de graves"
},
"low_gain_dB": {
"name": "ganho_baixo_dB",
"tooltip": "Ganho para frequências baixas (Grave)"
},
"mid_freq": {
"name": "freq_média",
"tooltip": "Frequência central para médios"
},
"mid_gain_dB": {
"name": "ganho_médio_dB",
"tooltip": "Ganho para frequências médias"
},
"mid_q": {
"name": "q_médio",
"tooltip": "Fator Q (largura de banda) para médios"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"AudioMerge": {
"description": "Combina duas faixas de áudio sobrepondo suas formas de onda.",
"display_name": "Mesclar Áudio",
@@ -4292,26 +4251,6 @@
}
}
},
"ImageCropV2": {
"display_name": "Corte de Imagem",
"inputs": {
"crop_region": {
"name": "região_de_corte"
},
"height": {},
"image": {
"name": "imagem"
},
"width": {},
"x": {},
"y": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
"display_name": "Remover Duplicatas de Imagem",
"inputs": {
@@ -10401,32 +10340,6 @@
}
}
},
"NAGuidance": {
"description": "Aplica Orientação de Atenção Normalizada aos modelos, permitindo prompts negativos em modelos distilled/schnell.",
"display_name": "Orientação de Atenção Normalizada",
"inputs": {
"model": {
"name": "modelo",
"tooltip": "O modelo ao qual aplicar o NAG."
},
"nag_alpha": {
"name": "nag_alpha",
"tooltip": "Fator de mesclagem para a atenção normalizada. 1.0 é substituição total, 0.0 não tem efeito."
},
"nag_scale": {
"name": "escala_nag",
"tooltip": "O fator de escala da orientação. Valores mais altos afastam mais do prompt negativo."
},
"nag_tau": {
"name": "nag_tau"
}
},
"outputs": {
"0": {
"tooltip": "O modelo modificado com NAG ativado."
}
}
},
"NormalizeImages": {
"display_name": "Normalizar Imagens",
"inputs": {
@@ -11238,28 +11151,6 @@
}
}
},
"PrimitiveBoundingBox": {
"display_name": "Caixa Delimitadora",
"inputs": {
"height": {
"name": "altura"
},
"width": {
"name": "largura"
},
"x": {
"name": "x"
},
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PrimitiveFloat": {
"display_name": "Ponto Flutuante",
"inputs": {
@@ -11823,88 +11714,6 @@
}
}
},
"RecraftV4TextToImageNode": {
"description": "Gera imagens usando os modelos Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texto para Imagem",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"model": {
"name": "modelo",
"tooltip": "O modelo a ser usado para geração."
},
"model_size": {
"name": "tamanho"
},
"n": {
"name": "n",
"tooltip": "O número de imagens a serem geradas."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
},
"seed": {
"name": "semente",
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftV4TextToVectorNode": {
"description": "Gera SVG usando os modelos Recraft V4 ou V4 Pro.",
"display_name": "Recraft V4 Texto para Vetor",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"model": {
"name": "modelo",
"tooltip": "O modelo a ser usado para geração."
},
"model_size": {
"name": "tamanho"
},
"n": {
"name": "n",
"tooltip": "O número de imagens a serem geradas."
},
"negative_prompt": {
"name": "prompt_negativo",
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
},
"recraft_controls": {
"name": "recraft_controls",
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
},
"seed": {
"name": "semente",
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
"description": "Gera SVG de forma síncrona a partir de uma imagem de entrada.",
"display_name": "Recraft Vetorizar Imagem",
@@ -16050,46 +15859,6 @@
}
}
},
"Vidu3StartEndToVideoNode": {
"description": "Gere um vídeo a partir de um quadro inicial, um quadro final e um prompt.",
"display_name": "Geração de Vídeo Quadro Inicial/Final Vidu Q3",
"inputs": {
"control_after_generate": {
"name": "controle após gerar"
},
"end_frame": {
"name": "quadro final"
},
"first_frame": {
"name": "quadro inicial"
},
"model": {
"name": "modelo",
"tooltip": "Modelo a ser usado para geração de vídeo."
},
"model_audio": {
"name": "áudio"
},
"model_duration": {
"name": "duração"
},
"model_resolution": {
"name": "resolução"
},
"prompt": {
"name": "prompt",
"tooltip": "Descrição do prompt (máx. 2000 caracteres)."
},
"seed": {
"name": "semente"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Vidu3TextToVideoNode": {
"description": "Gere um vídeo a partir de um prompt de texto.",
"display_name": "Geração de Vídeo de Texto para Vídeo Vidu Q3",

View File

@@ -350,10 +350,6 @@
"name": "Usar a fila de tarefas unificada no painel lateral de Assets",
"tooltip": "Substitui o painel flutuante de fila de tarefas por uma fila de tarefas equivalente incorporada ao painel lateral de Assets. Você pode desativar isso para voltar ao layout do painel flutuante."
},
"Comfy_RightSidePanel_ShowErrorsTab": {
"name": "Mostrar aba de erros no painel lateral",
"tooltip": "Quando ativado, uma aba de erros é exibida no painel lateral direito para mostrar rapidamente os erros de execução do fluxo de trabalho."
},
"Comfy_Sidebar_Location": {
"name": "Localização da barra lateral",
"options": {

View File

@@ -608,7 +608,6 @@
"AUDIO_ENCODER_OUTPUT": "ВЫХОД_АУДИО_КОДЕРА",
"AUDIO_RECORD": "АУДИО_ЗАПИСЬ",
"BOOLEAN": "БУЛЕВО",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "УПРАВЛЕНИЕ_КАМЕРОЙ",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
@@ -1899,25 +1898,6 @@
"outputs": "Выходы",
"type": "Тип"
},
"nodeReplacement": {
"compatibleAlternatives": "Совместимые альтернативы",
"installMissingNodes": "Установить отсутствующие узлы",
"installationRequired": "Требуется установка",
"instructionMessage": "Вам необходимо установить эти узлы или заменить их установленными альтернативами, чтобы запустить рабочий процесс. Отсутствующие узлы выделены {red} на холсте. Некоторые узлы нельзя заменить, их нужно установить через Менеджер узлов.",
"notReplaceable": "Требуется установка",
"openNodeManager": "Открыть Менеджер узлов",
"quickFixAvailable": "Доступно быстрое исправление",
"redHighlight": "красным",
"replaceFailed": "Не удалось заменить узлы",
"replaceSelected": "Заменить выбранные ({count})",
"replaceWarning": "Это действие навсегда изменит рабочий процесс. Сохраните копию, если не уверены.",
"replaceable": "Можно заменить",
"replaced": "Заменено",
"replacedAllNodes": "Заменено {count} типов(а) узлов",
"replacedNode": "Заменённый узел: {nodeType}",
"selectAll": "Выбрать все",
"skipForNow": "Пропустить сейчас"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -2016,11 +1996,6 @@
"advancedInputs": "РАСШИРЕННЫЕ ВХОДНЫЕ ДАННЫЕ",
"bypass": "Обход",
"color": "Цвет узла",
"enterSubgraph": "Войти в подграф",
"errorHelp": "Для получения дополнительной помощи {github} или {support}",
"errorHelpGithub": "создайте issue на GitHub",
"errorHelpSupport": "свяжитесь с нашей поддержкой",
"errors": "Ошибки",
"fallbackGroupTitle": "Группа",
"fallbackNodeTitle": "Узел",
"favorites": "ИЗБРАННЫЕ ВХОДЫ",
@@ -2055,7 +2030,6 @@
"inputsNoneTooltip": "Узел не имеет входов",
"locateNode": "Найти узел на холсте",
"mute": "Отключить",
"noErrors": "Ошибок нет",
"noSelection": "Выберите узел, чтобы увидеть его свойства и информацию.",
"nodeState": "Состояние узла",
"nodes": "Узлы",
@@ -2064,19 +2038,10 @@
"normal": "Обычный",
"parameters": "Параметры",
"pinned": "Закреплено",
"promptErrors": {
"no_prompt": {
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
},
"prompt_no_outputs": {
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Предпросмотр изображения) для получения результата."
}
},
"properties": "Свойства",
"removeFavorite": "Убрать из избранного",
"resetAllParameters": "Сбросить все параметры",
"resetToDefault": "Сбросить по умолчанию",
"seeError": "Посмотреть ошибку",
"settings": "Настройки",
"showAdvancedInputsButton": "Показать расширенные входные данные",
"showInput": "Показать вход",
@@ -2301,7 +2266,6 @@
"CustomColorPalettes": "Пользовательские цветовые палитры",
"DevMode": "Режим разработчика",
"EditTokenWeight": "Редактировать вес токена",
"Error System": "Система ошибок",
"Execution": "Выполнение",
"Extension": "Расширение",
"General": "Общие",
@@ -2450,11 +2414,8 @@
"moreOptions": "Больше опций",
"noActiveJobs": "Нет активных заданий",
"preview": "Предпросмотр",
"queuedJobsLabel": "{count} в очереди",
"queuedSuffix": "в очереди",
"running": "выполняется",
"runningJobsLabel": "{count} выполняется",
"runningQueuedSummary": "{running}, {queued}",
"showAssets": "Показать ассеты",
"showAssetsPanel": "Показать панель ассетов",
"sortBy": "Сортировать по",

Some files were not shown because too many files have changed in this diff Show More