Compare commits

..

1 Commits

Author SHA1 Message Date
PabloWiedemann
714a11872f feat: add outline button variant
Add a new `outline` variant to the Button component with transparent
background, subtle border stroke, and hover state. Automatically
reflected in Storybook via the existing AllVariants story.
2026-03-21 17:35:50 -07:00
235 changed files with 2477 additions and 12840 deletions

View File

@@ -30,7 +30,6 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -18,7 +18,6 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:

View File

@@ -51,9 +51,6 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -1,407 +0,0 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -91,12 +91,6 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -28,15 +28,10 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -81,10 +76,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -110,4 +101,3 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -16,30 +14,3 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -38,13 +38,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
@@ -99,13 +102,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',

View File

@@ -1,318 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -169,19 +165,17 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
@@ -210,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,86 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -1,117 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -1,132 +0,0 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe(
'Subgraph slot alignment after LG layout scale',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

View File

@@ -206,31 +206,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -47,46 +47,6 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,14 +19,10 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

View File

@@ -46,16 +46,4 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await textarea.blur()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
})

View File

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

View File

@@ -9,10 +9,13 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -20,6 +23,7 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -94,17 +98,12 @@ onMounted(() => {
}
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -25,8 +25,10 @@
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#FFF",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
@@ -35,6 +37,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#222",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -26,8 +26,10 @@
"CLEAR_BACKGROUND_COLOR": "lightgray",
"NODE_TITLE_COLOR": "#222",
"NODE_SELECTED_TITLE_COLOR": "#000",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#444",
"NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#F7F7F7",
"NODE_DEFAULT_BGCOLOR": "#F5F5F5",
"NODE_DEFAULT_BOXCOLOR": "#CCC",
@@ -36,6 +38,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#D4D4D4",
"WIDGET_OUTLINE_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#222",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -19,7 +19,9 @@
"litegraph_base": {
"NODE_TITLE_COLOR": "#fdf6e3",
"NODE_SELECTED_TITLE_COLOR": "#A9D400",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#657b83",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#094656",
"NODE_DEFAULT_BGCOLOR": "#073642",
"NODE_DEFAULT_BOXCOLOR": "#839496",
@@ -28,6 +30,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#002b36",
"WIDGET_OUTLINE_COLOR": "#839496",
"WIDGET_TEXT_COLOR": "#fdf6e3",

View File

@@ -186,13 +186,13 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
if (await authActions.signInWithGithub()) {
onSuccess()
}
}

View File

@@ -19,7 +19,10 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -49,12 +49,7 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -195,7 +195,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
@@ -456,9 +455,8 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -578,18 +576,6 @@ onMounted(async () => {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -84,9 +84,7 @@ watch(
pos: group.pos,
size: [group.size[0], group.titleHeight]
})
inputFontStyle.value = {
fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px`
}
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
} else if (target instanceof LGraphNode) {
const node = target
const [x, y] = node.getBounding()

View File

@@ -1,60 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,215 +1,207 @@
<template>
<ComboboxRoot
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
:class="
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ selectedCount }}
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -224,9 +216,22 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<SelectOption[]>({
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -234,16 +239,15 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
const fuseOptions: UseFuseOptions<SelectOption> = {
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -252,20 +256,23 @@ const fuseOptions: UseFuseOptions<SelectOption> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, () => options, fuseOptions)
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return options
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: SelectOption }) => result.item
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: SelectOption) => result.value === item.value)
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,12 +1,21 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -14,107 +23,121 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -129,12 +152,16 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -142,8 +169,6 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -156,8 +181,26 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
</script>

View File

@@ -14,11 +14,6 @@ const meta: Meta<typeof Loader> = {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Spinner size: sm (16px), md (32px), lg (48px)'
},
variant: {
control: 'select',
options: ['loader', 'loader-circle'],
description: 'The type of loader displayed'
}
}
}

View File

@@ -9,15 +9,12 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -41,21 +38,12 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)

View File

@@ -237,11 +237,6 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -90,7 +90,6 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -100,7 +99,6 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -127,10 +125,12 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,8 +154,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -176,6 +178,23 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,5 +1,7 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -40,25 +42,23 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
get isRestarting() {
return mockIsRestarting.value
},
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,9 +209,12 @@ describe('TabErrors.vue', () => {
}
})
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -242,9 +245,5 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,7 +53,6 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -210,9 +209,12 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
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 { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -236,7 +238,6 @@ import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -245,7 +246,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,13 +372,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -417,4 +418,20 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
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'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,7 +80,8 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -1,39 +0,0 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,7 +58,6 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -127,9 +126,8 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -148,9 +146,8 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -167,9 +164,8 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -181,9 +177,8 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -195,9 +190,8 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -212,9 +206,8 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -231,9 +224,8 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -245,9 +237,8 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -262,9 +253,8 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -282,9 +272,8 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -544,18 +533,13 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})

View File

@@ -5,7 +5,6 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -196,8 +195,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -237,7 +240,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -283,7 +285,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -405,7 +407,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -446,8 +448,6 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,18 +459,8 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -532,7 +522,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -556,7 +546,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,8 +2,6 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -42,33 +40,24 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
const workflow = app.rootGraph.serialize()
for (const { error, idx } of runtimeErrors) {
try {
@@ -83,8 +72,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
} catch {
// Fallback: keep original error.details
}
}
})

View File

@@ -23,7 +23,9 @@ export const buttonVariants = cva({
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
outline:
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -55,7 +57,8 @@ const variants = [
'link',
'base',
'overlay-white',
'gradient'
'gradient',
'outline'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -140,19 +140,13 @@ export const useFirebaseAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGoogle()
}, reportError)
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGithub()
}, reportError)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {

View File

@@ -315,45 +315,6 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,22 +35,10 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -94,15 +82,6 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -112,15 +91,6 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -132,17 +102,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -197,16 +197,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// Create a PromotedWidgetView with displayName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
undefined,
'value'
)

View File

@@ -92,10 +92,6 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -356,8 +352,7 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -808,8 +803,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,111 +0,0 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -86,24 +86,6 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (!graph) {
comfyApp.canvas?.setDirty(true, true)
return
}
for (const node of graph._nodes) {
if (node.flags.collapsed) continue
try {
node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
exceedsClickThreshold,
useClickDragGuard
} from '@/composables/useClickDragGuard'
describe('exceedsClickThreshold', () => {
it('returns false when distance is within threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
})
it('returns true when distance exceeds threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
})
it('returns false when distance exactly equals threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
})
it('handles negative deltas', () => {
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
true
)
})
})
describe('useClickDragGuard', () => {
it('reports no drag when pointer has not moved', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('reports no drag when movement is within threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
})
it('reports drag when movement exceeds threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
})
it('returns false when no start has been recorded', () => {
const guard = useClickDragGuard(5)
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('returns false after reset', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
guard.reset()
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
})
it('respects custom threshold', () => {
const guard = useClickDragGuard(3)
guard.recordStart({ clientX: 0, clientY: 0 })
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
})
})

View File

@@ -1,41 +0,0 @@
interface PointerPosition {
readonly x: number
readonly y: number
}
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}
export function exceedsClickThreshold(
start: PointerPosition,
end: PointerPosition,
threshold: number
): boolean {
return squaredDistance(start, end) > threshold * threshold
}
export function useClickDragGuard(threshold: number = 5) {
let start: PointerPosition | null = null
function recordStart(e: { clientX: number; clientY: number }) {
start = { x: e.clientX, y: e.clientY }
}
function wasDragged(e: { clientX: number; clientY: number }): boolean {
if (!start) return false
return exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
threshold
)
}
function reset() {
start = null
}
return { recordStart, wasDragged, reset }
}

View File

@@ -107,27 +107,6 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -138,18 +138,15 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses identityName when provided, label uses displayName', () => {
test('name uses displayName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label',
undefined,
'my_slot'
'Custom Label'
)
expect(view.name).toBe('my_slot')
expect(view.label).toBe('Custom Label')
expect(view.name).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
@@ -337,11 +334,11 @@ describe(createPromotedWidgetView, () => {
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBeUndefined()
expect(view1.label).toBe('myWidget')
// With displayName → label falls back to displayName
// With displayName → falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -1015,9 +1012,7 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
// .name stays as identity (subgraph input name), .label updates for display
expect(afterRename.name).toBe('seed')
expect(afterRename.label).toBe('seed_renamed')
expect(afterRename.name).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {

View File

@@ -27,12 +27,6 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -56,16 +50,14 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
disambiguatingSourceNodeId
)
}
@@ -91,17 +83,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -113,7 +100,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
return this.displayName ?? this.sourceWidgetName
}
get y(): number {
@@ -201,58 +188,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
return state?.label ?? this.displayName ?? this.sourceWidgetName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -294,27 +238,21 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
}
onPointerDown(

View File

@@ -30,7 +30,6 @@ describe('PrimitiveFloat widget type bridging', () => {
})
Object.defineProperty(widget.options, 'gradient_stops', {
enumerable: true,
get: () => properties.gradient_stops,
set: (v) => {
properties.gradient_stops = v
@@ -83,20 +82,6 @@ describe('PrimitiveFloat widget type bridging', () => {
expect(widget.options.gradient_stops).toBe(stops)
})
it('gradient_stops survives object spread', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
const stops = [
{ offset: 0, color: [0, 255, 255] },
{ offset: 1, color: [255, 0, 0] }
]
properties.gradient_stops = stops
const spread = { ...widget.options }
expect(spread.gradient_stops).toBe(stops)
})
it('writes gradient_stops back to properties', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)

View File

@@ -124,28 +124,6 @@ function onCustomComboCreated(this: LGraphNode) {
addOption(this)
}
function onBranchSelectorCreated(this: LGraphNode) {
this.applyToGraph = applyToGraph
this.widgets?.pop()
const values = () =>
this.inputs.slice(0, -1).map((i) => i.label ?? i.localized_name ?? i.name)
const comboWidget = this.addWidget('combo', 'branch', '', () => {}, {
values
})
comboWidget.serializeValue = () =>
values().findIndex((e) => e === comboWidget.value)
this.onConnectionsChange = useChainCallback(this.onConnectionsChange, () =>
requestAnimationFrame(() => {
const vals = values()
if (app.configuringGraph) return
if (vals.includes(`${comboWidget.value}`)) return
comboWidget.value = vals[0] ?? ''
comboWidget.callback?.(comboWidget.value)
})
)
}
function onCustomIntCreated(this: LGraphNode) {
const valueWidget = this.widgets?.[0]
if (!valueWidget) return
@@ -191,7 +169,6 @@ function onCustomFloatCreated(this: LGraphNode) {
})
Object.defineProperty(valueWidget.options, 'gradient_stops', {
enumerable: true,
get: () => this.properties.gradient_stops,
set: (v) => {
this.properties.gradient_stops = v
@@ -249,11 +226,6 @@ app.registerExtension({
nodeType.prototype.onNodeCreated,
onCustomComboCreated
)
else if (nodeData?.name === 'BranchNode')
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
onBranchSelectorCreated
)
else if (nodeData?.name === 'PrimitiveInt')
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -70,7 +68,9 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
@@ -197,20 +197,18 @@ class Load3d {
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
@@ -219,13 +217,12 @@ class Load3d {
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false

View File

@@ -7,8 +7,7 @@ import {
LGraph,
LGraphNode,
LiteGraph,
LLink,
Reroute
LLink
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
@@ -18,7 +17,6 @@ import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import { subgraphTest } from './subgraph/__fixtures__/subgraphFixtures'
import {
duplicateLinksRoot,
@@ -100,42 +98,6 @@ describe('LGraph', () => {
const fromOldSchema = new LGraph(oldSchemaGraph)
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
})
subgraphTest('should snap slots to same y-level', ({ emptySubgraph }) => {
const node = new LGraphNode('testname')
node.addInput('test', 'IMAGE')
emptySubgraph.add(node)
emptySubgraph.inputNode.pos = [0, 0]
// Reroute needs offset of ~20y to align with first slot
const reroute = new Reroute(1, emptySubgraph, [0, 20])
node.snapToGrid(10)
reroute.snapToGrid(10)
emptySubgraph.inputNode.snapToGrid(10)
node.arrange()
emptySubgraph.inputNode.arrange()
const yPos = node.getInputPos(0)[1]
expect(reroute.pos[1]).toBe(yPos)
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos)
// Assign non-equal positions and repeat
emptySubgraph.inputNode.pos = [0, 43]
node.pos = [0, 50]
reroute.pos = [0, 63]
node.snapToGrid(10)
reroute.snapToGrid(10)
emptySubgraph.inputNode.snapToGrid(10)
node.arrange()
emptySubgraph.inputNode.arrange()
const yPos2 = node.getInputPos(0)[1]
expect(reroute.pos[1]).toBe(yPos2)
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos2)
})
})
describe('Floating Links / Reroutes', () => {

View File

@@ -1,207 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/LLink'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn(),
getCurrentSource: vi.fn(() => 'test'),
getCurrentActor: vi.fn(() => 'test'),
applyOperation: vi.fn(),
pendingSlotSync: false
}
}))
function createMockCtx(): CanvasRenderingContext2D {
return createMockCanvas2DContext({
translate: vi.fn(),
scale: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
closePath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn()
}),
bezierCurveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
isPointInStroke: vi.fn().mockReturnValue(false),
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
imageSmoothingEnabled: true
})
}
/**
* Creates a link between two nodes by directly mutating graph state,
* bypassing the layout store integration in connect().
*/
function createTestLink(
graph: LGraph,
sourceNode: LGraphNode,
outputSlot: number,
targetNode: LGraphNode,
inputSlot: number
): LLink {
const linkId = ++graph.state.lastLinkId
const link = new LLink(
linkId,
sourceNode.outputs[outputSlot].type,
sourceNode.id,
outputSlot,
targetNode.id,
inputSlot
)
graph._links.set(linkId, link)
sourceNode.outputs[outputSlot].links ??= []
sourceNode.outputs[outputSlot].links!.push(linkId)
targetNode.inputs[inputSlot].link = linkId
return link
}
describe('drawConnections widget-input slot positioning', () => {
let graph: LGraph
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
LiteGraph.vueNodesMode = false
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
it('arranges widget-input slots before rendering links', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
const widget = targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Before drawConnections, input.pos should not be set
expect(input.pos).toBeUndefined()
canvas.drawConnections(createMockCtx())
// After drawConnections, input.pos should be set to the widget row
expect(input.pos).toBeDefined()
expect(input.pos![1]).toBeGreaterThan(0)
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Pre-arrange so input.pos is already set
targetNode._setConcreteSlots()
targetNode.arrange()
expect(input.pos).toBeDefined()
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
canvas.drawConnections(createMockCtx())
expect(arrangeSpy).not.toHaveBeenCalled()
})
it('positions widget-input slots when display name differs from slot.widget.name', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
// Widget has a display name that differs from the slot's widget.name
// (simulates a renamed subgraph label)
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
const input = targetNode.addInput('renamed_label', 'STRING')
input.widget = { name: 'original_name' }
// Bind the widget as the slot's _widget (preferred over name-map lookup)
input._widget = widget
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
canvas.drawConnections(createMockCtx())
expect(input.pos).toBeDefined()
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
})

View File

@@ -2616,7 +2616,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
pointer.finally = () => (this.resizingGroup = null)
} else {
const headerHeight = LiteGraph.NODE_TITLE_HEIGHT
const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const headerHeight = f * 1.4
if (
isInRectangle(
x,
@@ -5881,8 +5882,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
drawSnapGuide(
ctx: CanvasRenderingContext2D,
item: Positionable,
shape = RenderShape.ROUND,
{ offsetToSlot }: { offsetToSlot?: boolean } = {}
shape = RenderShape.ROUND
) {
const snapGuide = temp
snapGuide.set(item.boundingRect)
@@ -5890,10 +5890,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Not all items have pos equal to top-left of bounds
const { pos } = item
const offsetX = pos[0] - snapGuide[0]
const offsetY =
pos[1] -
snapGuide[1] -
(offsetToSlot ? LiteGraph.NODE_SLOT_HEIGHT * 0.7 : 0)
const offsetY = pos[1] - snapGuide[1]
// Normalise boundingRect to pos to snap
snapGuide[0] += offsetX
@@ -5953,19 +5950,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = this.editor_alpha
// for every node
const nodes = graph._nodes
// Ensure widget-input slot positions are computed before rendering links.
// arrange() sets input.pos for widget-backed slots, but is normally called
// in drawNode (foreground canvas). drawConnections runs on the background
// canvas, which may render before drawNode has executed for this frame.
// The dirty flag avoids a per-frame O(N) scan of all inputs.
for (const node of nodes) {
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
node._setConcreteSlots()
node.arrange()
}
for (const node of nodes) {
// for every input (we render just inputs because it is easier as every slot can only have one input)
const { inputs } = node
@@ -6083,9 +6067,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.isDragging &&
this.selectedItems.has(reroute)
) {
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE, {
offsetToSlot: true
})
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
}
reroute.draw(ctx, this._pattern)

View File

@@ -40,7 +40,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
color?: string
title: string
font?: string
font_size: number = LiteGraph.GROUP_TEXT_SIZE
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
_bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight)
_pos: Point = this._bounding.pos
@@ -116,7 +116,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
}
get titleHeight() {
return LiteGraph.NODE_TITLE_HEIGHT
return this.font_size * 1.4
}
get children(): ReadonlySet<Positionable> {
@@ -148,6 +148,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
this._bounding.set(o.bounding)
this.color = o.color
this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size
}
serialize(): ISerialisedGroup {
@@ -157,6 +158,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
title: this.title,
bounding: [...b],
color: this.color,
font_size: this.font_size,
flags: this.flags
}
}
@@ -168,7 +170,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
*/
draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void {
const { padding, resizeLength, defaultColour } = LGraphGroup
const font_size = LiteGraph.GROUP_TEXT_SIZE
const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
const [x, y] = this._pos
const [width, height] = this._size
@@ -179,7 +181,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.beginPath()
ctx.rect(x + 0.5, y + 0.5, width, LiteGraph.NODE_TITLE_HEIGHT)
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
ctx.fill()
// Group background, border
@@ -201,13 +203,11 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Title
ctx.font = `${font_size}px ${LiteGraph.GROUP_FONT}`
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(
this.title + (this.pinned ? '📌' : ''),
x + font_size / 2,
y + LiteGraph.NODE_TITLE_HEIGHT / 2 + 1
x + padding,
y + font_size
)
ctx.textBaseline = 'alphabetic'
if (LiteGraph.highlight_selected_group && this.selected) {
strokeShape(ctx, this._bounding, {

View File

@@ -295,12 +295,6 @@ export class LGraphNode
*/
freeWidgetSpace?: number
/**
* Set to true when widget-backed input slot positions need recalculation.
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
*/
_widgetSlotsDirty = false
locked?: boolean
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
@@ -1998,7 +1992,6 @@ export class LGraphNode
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
@@ -2038,11 +2031,9 @@ export class LGraphNode
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
input.pos = undefined
}
}
}
this._widgetSlotsDirty = true
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
@@ -4215,29 +4206,40 @@ export class LGraphNode
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
if (!this.widgets?.length) return
if (!this.widgets) return
// Build a name→widget map for fast lookup.
const widgetByName = new Map<string, IBaseWidget>()
for (const w of this.widgets) widgetByName.set(w.name, w)
const slotByWidgetName = new Map<
string,
INodeInputSlot & { index: number }
>()
// Set widget-backed slot positions from widget Y coordinates.
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
// components (NodeSlots filters them out), so they have no DOM-registered
// position. input.pos serves as the fallback for getSlotPosition().
for (const [i, slot] of this._concreteInputs.entries()) {
for (const [i, slot] of this.inputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
// Fall back to name-map lookup for regular nodes without _widget set.
// Note: the name-map is ambiguous if two promoted inputs share a label;
// _widget avoids this since it is a direct reference.
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
if (!widget) continue
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
}
if (!slotByWidgetName.size) return
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, true)
// Only set custom pos if not using Vue positioning
// Vue positioning calculates widget slot positions dynamically
if (!LiteGraph.vueNodesMode) {
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this._concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this._measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
}
}
}
@@ -4267,7 +4269,6 @@ export class LGraphNode
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this._widgetSlotsDirty = false
}
/**

View File

@@ -72,7 +72,8 @@ export class LiteGraphGlobal {
DEFAULT_FONT = 'Inter'
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
GROUP_TEXT_SIZE = 20
DEFAULT_GROUP_FONT = 24
DEFAULT_GROUP_FONT_SIZE = 24
GROUP_FONT = 'Inter'
WIDGET_BGCOLOR = '#222'

View File

@@ -16,7 +16,6 @@ import type {
ReadOnlyRect,
ReadonlyLinkNetwork
} from './interfaces'
import { LiteGraph } from './litegraph'
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
@@ -429,10 +428,9 @@ export class Reroute
snapToGrid(snapTo: number): boolean {
if (!snapTo) return false
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
const { pos } = this
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true
}

View File

@@ -18,6 +18,7 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
],
"color": "#6029aa",
"flags": {},
"font_size": 14,
"id": 123,
"title": "A group to test with",
},

View File

@@ -10,6 +10,7 @@ exports[`LGraphGroup > serializes to the existing format > Basic 1`] = `
],
"color": "#3f789e",
"flags": {},
"font_size": 24,
"id": 929,
"title": "title",
}

View File

@@ -21,6 +21,8 @@ LiteGraphGlobal {
"ContextMenu": [Function],
"CurveEditor": [Function],
"DEFAULT_FONT": "Inter",
"DEFAULT_GROUP_FONT": 24,
"DEFAULT_GROUP_FONT_SIZE": 24,
"DEFAULT_POSITION": [
100,
100,
@@ -32,7 +34,6 @@ LiteGraphGlobal {
"EVENT_LINK_COLOR": "#A86",
"GRID_SHAPE": 6,
"GROUP_FONT": "Inter",
"GROUP_TEXT_SIZE": 20,
"Globals": {},
"HIDDEN_LINK": -1,
"INPUT": 1,

View File

@@ -1,8 +1,8 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
@@ -15,9 +15,9 @@ export class PromotedWidgetViewManager<TView> {
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
reconcile(
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)

View File

@@ -36,7 +36,7 @@ export abstract class SubgraphIONodeBase<
{
static margin = 10
static minWidth = 100
static roundedRadius = 14 // Matches NODE_SLOT_HEIGHT * 0.7 for slot alignment
static roundedRadius = 10
private readonly _boundingRect: Rectangle = new Rectangle()

View File

@@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -196,258 +196,6 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', '', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
expect(promotedInput.widget).toBeDefined()
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'text',
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'INT')
interiorNode.addWidget('number', 'value', 0, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'seed',
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
'My Seed'
)
})
})
describe('SubgraphNode widget name collision on rename', () => {
it('should not collapse two inputs when renamed to the same label', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'prompt_a', type: 'STRING' },
{ name: 'prompt_b', type: 'STRING' }
]
})
// Create two interior nodes with widgets
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs).toHaveLength(2)
// widget.name is now nodeId:widgetName (stable composite key)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename prompt_b to same LABEL as prompt_a
subgraph.inputs[1].label = 'prompt_a'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'prompt_b',
newName: 'prompt_a'
})
// Both inputs survive — widget.name stays as composite key, no collision
expect(subgraphNode.inputs).toHaveLength(2)
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should keep unique widget.name keys even with duplicate labels', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: 'INT' },
{ name: 'seed2', type: 'INT' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'INT')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'INT')
nodeA.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'INT')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'INT')
nodeB.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
// Keys should be unique composite identifiers (nodeId:widgetName)
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename seed2 to "seed" — duplicate display label
subgraph.inputs[1].label = 'seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'seed2',
newName: 'seed'
})
// Widget keys remain stable — rename only affects display label
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should not lose input when onGraphConfigured runs after duplicate rename', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'STRING' },
{ name: 'beta', type: 'STRING' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
// Rename beta to "alpha" — collision
subgraph.inputs[1].label = 'alpha'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'beta',
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.inputs).toHaveLength(2)
})
})
describe('SubgraphNode Lifecycle', () => {

View File

@@ -63,8 +63,6 @@ workflowSvg.src =
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
/** The subgraph input slot's internal name (stable identity). */
slotName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
@@ -194,7 +192,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
@@ -209,7 +206,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -281,8 +277,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId,
entry.slotName
entry.disambiguatingSourceNodeId
)
)
@@ -338,7 +333,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -568,22 +562,17 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
@@ -791,12 +780,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
// Do NOT change input.widget.name — it is the stable internal
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
if (input._widget) {
input._widget.label = newName
}
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -1148,13 +1134,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Binds a promoted widget view to a subgraph input slot.
*
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
* promotion store, sets up the prototype chain for multi-level subgraph
* nesting, and dispatches the `widget-promoted` event.
*/
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
@@ -1208,10 +1187,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
// the internal name independently of the interior node.
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1221,8 +1197,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId,
subgraphInput.name
sourceNodeId
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1236,9 +1211,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
// Use subgraphInput.name as the stable identity — unique per subgraph
// slot, immune to label renames. Matches PromotedWidgetView.name.
// Display is handled via widget.label / PromotedWidgetView.label.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -15,14 +15,6 @@
"message": "يحتوي سير العمل هذا على عقد API، والتي تتطلب تسجيل دخولك إلى حسابك لتشغيلها.",
"title": "تسجيل الدخول مطلوب لاستخدام عقد API"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "لأفضل تجربة، يستخدم منشئ التطبيقات Nodes 2.0. يمكنك العودة بعد بناء التطبيق من القائمة الرئيسية.",
"dismiss": "تجاهل",
"dontShowAgain": "عدم الإظهار مرة أخرى",
"title": "تم التبديل إلى Nodes 2.0"
}
},
"assetBrowser": {
"allCategory": "جميع {category}",
"allModels": "جميع النماذج",
@@ -780,7 +772,6 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "تكييف",
"CONTROL_NET": "ControlNet",
"CURVE": "منحنى",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "ملف ثلاثي الأبعاد",
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
@@ -794,7 +785,6 @@
"GEMINI_INPUT_FILES": "ملفات إدخال جيميني",
"GLIGEN": "GLIGEN",
"GUIDER": "موجه",
"HISTOGRAM": "مخطط بياني",
"HOOKS": "معالجات",
"HOOK_KEYFRAMES": "مفاتيح المعالجات",
"IMAGE": "صورة",
@@ -886,8 +876,6 @@
"resume": "استئناف التنزيل"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "حدث خطأ",
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
@@ -936,19 +924,6 @@
"textToImage": "تحويل نص إلى صورة",
"textToVideo": "تحويل نص إلى فيديو"
},
"execution": {
"decoding": "جارٍ فك الترميز…",
"encoding": "جارٍ الترميز…",
"generating": "جارٍ التوليد…",
"generatingVideo": "جارٍ توليد الفيديو…",
"loading": "جارٍ التحميل…",
"processing": "جارٍ المعالجة…",
"processingVideo": "جارٍ معالجة الفيديو…",
"resizing": "جارٍ تغيير الحجم…",
"running": "جارٍ التشغيل…",
"saving": "جارٍ الحفظ…",
"training": "جارٍ التدريب…"
},
"exportToast": {
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
"downloadExport": "تحميل التصدير",
@@ -1089,14 +1064,11 @@
"filterBy": "تصفية حسب:",
"filterByType": "تصفية حسب {type}...",
"findIssues": "العثور على مشاكل",
"findOnGithub": "ابحث في GitHub",
"frameNodes": "تأطير العقد",
"frontendNewer": "إصدار الواجهة الأمامية {frontendVersion} قد لا يكون متوافقاً مع الإصدار الخلفي {backendVersion}.",
"frontendOutdated": "إصدار الواجهة الأمامية {frontendVersion} قديم. يتطلب الإصدار الخلفي {requiredVersion} أو أحدث.",
"gallery": "المعرض",
"galleryImage": "صورة المعرض",
"galleryThumbnail": "صورة مصغرة للمعرض",
"getHelpAction": "الحصول على المساعدة",
"goToNode": "الانتقال إلى العقدة",
"graphNavigation": "التنقل في الرسم البياني",
"halfSpeed": "0.5x",
@@ -1105,8 +1077,6 @@
"icon": "أيقونة",
"imageDoesNotExist": "الصورة غير موجودة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imageGallery": "معرض الصور",
"imageLightbox": "معاينة الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
"imageUrl": "رابط الصورة",
"import": "استيراد",
@@ -1120,38 +1090,13 @@
"installed": "مثبت",
"installing": "جارٍ التثبيت",
"interrupted": "تمت المقاطعة",
"itemSelected": "تم تحديد عنصر واحد",
"itemsCopiedToClipboard": "تم نسخ العناصر إلى الحافظة",
"itemsSelected": "تم تحديد {selectedCount} عناصر",
"job": "مهمة",
"jobIdCopied": "تم نسخ معرف المهمة إلى الحافظة",
"keybinding": "اختصار لوحة المفاتيح",
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
"keybindingPresets": {
"default": "الإعداد المسبق الافتراضي",
"deletePreset": "حذف الإعداد المسبق",
"deletePresetFailed": "فشل في حذف الإعداد المسبق \"{name}\"",
"deletePresetTitle": "حذف الإعداد المسبق الحالي؟",
"deletePresetWarning": "سيتم حذف هذا الإعداد المسبق. لا يمكن التراجع عن ذلك.",
"discardAndSwitch": "تجاهل والانتقال",
"exportPreset": "تصدير الإعداد المسبق",
"importKeybindingPreset": "استيراد إعداد مفاتيح الاختصار",
"importPreset": "استيراد الإعداد المسبق",
"invalidPresetFile": "يجب أن يكون ملف الإعداد المسبق ملف JSON صالح تم تصديره من ComfyUI",
"invalidPresetName": "يجب ألا يكون اسم الإعداد المسبق فارغًا أو \"default\" أو يبدأ بنقطة أو يحتوي على فواصل مسار أو ينتهي بـ .json",
"loadPresetFailed": "فشل في تحميل الإعداد المسبق \"{name}\"",
"overwritePresetMessage": "يوجد إعداد مسبق باسم \"{name}\" بالفعل. هل تريد استبداله؟",
"overwritePresetTitle": "استبدال الإعداد المسبق",
"presetDeleted": "تم حذف الإعداد المسبق \"{name}\"",
"presetImported": "تم استيراد إعداد مفاتيح الاختصار",
"presetNamePrompt": "أدخل اسمًا للإعداد المسبق",
"presetSaved": "تم حفظ الإعداد المسبق \"{name}\"",
"resetToDefault": "إعادة التعيين إلى الافتراضي",
"saveAndSwitch": "حفظ والانتقال",
"saveAsNewPreset": "حفظ كإعداد مخصص جديد",
"saveChanges": "حفظ التغييرات",
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد إذا انتقلت دون حفظ.",
"unsavedChangesTo": "تغييرات غير محفوظة على {name}"
},
"keybindings": "اختصارات لوحة المفاتيح",
"learnMore": "اعرف المزيد",
"listening": "جاري الاستماع...",
@@ -1208,8 +1153,6 @@
"output": "إخراج",
"overwrite": "الكتابة فوق",
"partner": "شريك",
"pause": "إيقاف مؤقت",
"play": "تشغيل",
"playPause": "تشغيل/إيقاف مؤقت",
"playRecording": "تشغيل التسجيل",
"playbackSpeed": "سرعة التشغيل",
@@ -1217,7 +1160,6 @@
"preloadError": "فشل تحميل مورد مطلوب. يرجى إعادة تحميل الصفحة.",
"preloadErrorTitle": "خطأ في التحميل",
"preview": "معاينة",
"previous": "السابق",
"previousImage": "الصورة السابقة",
"profile": "الملف الشخصي",
"progressCountOf": "من",
@@ -1292,8 +1234,6 @@
"showReport": "عرض التقرير",
"showRightPanel": "إظهار اللوحة اليمنى",
"singleSelectDropdown": "قائمة منسدلة اختيار واحد",
"skipToEnd": "الانتقال للنهاية",
"skipToStart": "الانتقال للبداية",
"sort": "فرز",
"source": "المصدر",
"startRecording": "بدء التسجيل",
@@ -1325,7 +1265,6 @@
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
"videoFailedToLoad": "فشل تحميل الفيديو",
"videoPreview": "معاينة الفيديو - استخدم مفاتيح الأسهم للتنقل بين الفيديوهات",
"viewGrid": "عرض الشبكة",
"viewImageOfTotal": "عرض الصورة {index} من {total}",
"viewVideoOfTotal": "عرض الفيديو {index} من {total}",
"volume": "مستوى الصوت",
@@ -1893,7 +1832,6 @@
"mirrorVertical": "انعكاس عمودي",
"negative": "سلبي",
"opacity": "الشفافية",
"openMaskEditor": "فتح في محرر القناع",
"paintBucketSettings": "إعدادات دلو الطلاء",
"paintLayer": "طبقة الطلاء",
"redo": "إعادة",
@@ -2208,7 +2146,6 @@
"Moonvalley Marey": "مون فالي ماري",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "رودان",
@@ -2484,8 +2421,6 @@
"favoritesNoneDesc": "ستظهر المدخلات التي تضعها في المفضلة هنا",
"favoritesNoneHint": "في علامة تبويب المعلمات، انقر على {moreIcon} بجانب أي إدخال لإضافته هنا",
"favoritesNoneTooltip": "قم بوضع نجمة على الأدوات للوصول السريع إليها دون اختيار العقد",
"findOnGithubTooltip": "ابحث في مشكلات GitHub عن مشاكل مشابهة",
"getHelpTooltip": "أبلغ عن هذا الخطأ وسنساعدك في حله",
"globalSettings": {
"canvas": "اللوحة",
"connectionLinks": "روابط الاتصال",
@@ -3138,6 +3073,7 @@
"title": "تم إلغاء اشتراكك"
},
"changeTo": "تغيير إلى {plan}",
"chooseBestPlanWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "شعار Comfy Cloud",
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
@@ -3167,7 +3103,6 @@
},
"gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)",
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
"inviteUpTo": "ادعُ حتى",
"invoiceHistory": "سجل الفواتير",
"learnMore": "معرفة المزيد",
"managePayment": "إدارة الدفع",
@@ -3193,16 +3128,13 @@
"monthlyCreditsPerMemberLabel": "الرصيد الشهري / عضو",
"monthlyCreditsRollover": "سيتم ترحيل هذا الرصيد إلى الشهر التالي",
"mostPopular": "الأكثر شيوعًا",
"needTeamWorkspace": "هل تحتاج إلى مساحة عمل للفريق؟",
"nextBillingCycle": "دورة الفوترة التالية",
"nextMonthInvoice": "فاتورة الشهر القادم",
"partnerNodesBalance": "رصيد \"عُقَد الشريك\"",
"partnerNodesCredits": "رصيد العقد الشريكة",
"partnerNodesDescription": "لتشغيل النماذج التجارية/المملوكة",
"perMonth": "دولار أمريكي / شهر",
"personalWorkspace": "مساحة العمل الشخصية",
"plansAndPricing": "الخطط والأسعار",
"plansForWorkspace": "الخطط لمساحة العمل {workspace}",
"prepaidCreditsInfo": "رصيد تم شراؤه بشكل منفصل ولا ينتهي صلاحيته",
"prepaidDescription": "رصيد مسبق الدفع",
"preview": {
@@ -3238,7 +3170,6 @@
"resubscribe": "إعادة الاشتراك",
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
"resubscribeTo": "إعادة الاشتراك في {plan}",
"soloUseOnly": "للاستخدام الفردي فقط",
"subscribeForMore": "ترقية",
"subscribeNow": "اشترك الآن",
"subscribeTo": "اشترك في {plan}",
@@ -3246,7 +3177,6 @@
"subscribeToRun": "اشتراك",
"subscribeToRunFull": "الاشتراك للتشغيل",
"subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة",
"teamWorkspace": "مساحة عمل الفريق",
"tierNameYearly": "{name} سنوي",
"tiers": {
"creator": {
@@ -3298,18 +3228,6 @@
"duplicateTab": "تكرار التبويب",
"removeFromBookmarks": "إزالة من العلامات"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "تم إنشاء مساحة العمل لكن الإعداد غير مكتمل",
"createWorkspace": "إنشاء مساحة عمل",
"namePlaceholder": "مثال: فريق التسويق",
"nameValidationError": "يجب أن يكون الاسم من ١ إلى ٥٠ حرفًا باستخدام الحروف أو الأرقام أو المسافات أو علامات الترقيم الشائعة.",
"newWorkspace": "مساحة عمل جديدة",
"subtitle": "انتقل إلى مساحة عمل موجودة أو أنشئ مساحة عمل جديدة",
"subtitleNoWorkspaces": "أنشئ مساحة عمل فريق جديدة لمشاركة الرصيد",
"switch": "تبديل",
"title": "مساحات عمل الفريق",
"yourTeamWorkspaces": "مساحات عمل فريقك"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "بحث..."
@@ -3696,7 +3614,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "إنشاء مساحة عمل جديدة",
"failedToSwitch": "فشل في تبديل مساحة العمل",
"maxWorkspacesReached": "يمكنك امتلاك ١٠ مساحات عمل فقط. احذف واحدة لإنشاء مساحة جديدة.",
"personal": "شخصي",
"roleMember": "عضو",

View File

@@ -1438,22 +1438,6 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "تحويل الرقم",
"inputs": {
"value": {
"name": "القيمة"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "مفتاح التحويل",
"inputs": {
@@ -1752,10 +1736,6 @@
"name": "closed_loop",
"tooltip": "ما إذا كان سيتم إغلاق حلقة نافذة السياق؛ تنطبق فقط على الجداول الحلقية."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "قائمة مؤشرات latent التي سيتم الاحتفاظ بها في موترات التكييف لكل نافذة. على سبيل المثال، تعيين هذه القيمة إلى '0' سيستخدم صورة البداية الأولية لكل نافذة."
},
"context_length": {
"name": "context_length",
"tooltip": "طول نافذة السياق."
@@ -1787,10 +1767,6 @@
"model": {
"name": "model",
"tooltip": "النموذج المراد تطبيق نوافذ السياق عليه أثناء أخذ العينات."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "هل تريد تقسيم التكييفات المتعددة (التي تم إنشاؤها بواسطة ConditionCombine) إلى كل نافذة بناءً على مؤشر المنطقة."
}
},
"outputs": {
@@ -2234,23 +2210,6 @@
}
}
},
"CurveEditor": {
"display_name": "محرر المنحنى",
"inputs": {
"curve": {
"name": "منحنى"
},
"histogram": {
"name": "مخطط بياني"
}
},
"outputs": {
"0": {
"name": "منحنى",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "توليفة مخصصة",
"inputs": {
@@ -3856,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "الصورة الأولى من عملية تفكير النموذج. متوفرة فقط عند مستوى التفكير العالي ونمط IMAGE+TEXT."
}
}
},
@@ -4125,39 +4080,6 @@
}
}
},
"GrokVideoExtendNode": {
"description": "تمديد فيديو موجود باستمرار سلس بناءً على وصف نصي.",
"display_name": "تمديد فيديو Grok",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتمديد الفيديو."
},
"model_duration": {
"name": "المدة"
},
"prompt": {
"name": "الوصف النصي",
"tooltip": "وصف نصي لما يجب أن يحدث بعد ذلك في الفيديو."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
},
"video": {
"name": "فيديو",
"tooltip": "الفيديو المصدر للتمديد. صيغة MP4، من ٢ إلى ١٥ ثانية."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "توليد فيديو من مطالبة أو صورة",
"display_name": "فيديو Grok",
@@ -4198,41 +4120,6 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "توليد فيديو موجه بواسطة صور مرجعية كمرجع للأسلوب والمحتوى.",
"display_name": "Grok من مرجع إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج",
"tooltip": "النموذج المستخدم لتوليد الفيديو."
},
"model_aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"model_duration": {
"name": "المدة"
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "الوصف النصي",
"tooltip": "وصف نصي للفيديو المطلوب."
},
"seed": {
"name": "البذرة",
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "توسيع القناع",
"inputs": {
@@ -6849,54 +6736,6 @@
}
}
},
"LTXVReferenceAudio": {
"description": "تعيين صوت مرجعي لنقل هوية المتحدث باستخدام ID-LoRA. يقوم بترميز مقطع صوتي مرجعي إلى التكييف، ويمكنه أيضًا تعديل النموذج بتوجيه الهوية (تمرير إضافي للأمام بدون المرجع، مما يعزز تأثير هوية المتحدث).",
"display_name": "LTXV مرجع الصوت (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE للترميز."
},
"end_percent": {
"name": "نسبة النهاية",
"tooltip": "نهاية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
},
"identity_guidance_scale": {
"name": "مقياس توجيه الهوية",
"tooltip": "قوة توجيه الهوية. ينفذ تمريرًا إضافيًا للأمام بدون المرجع في كل خطوة لتعزيز هوية المتحدث. اضبط على ٠ للتعطيل (بدون تمرير إضافي)."
},
"model": {
"name": "النموذج"
},
"negative": {
"name": "سلبي"
},
"positive": {
"name": "إيجابي"
},
"reference_audio": {
"name": "الصوت_المرجعي",
"tooltip": "مقطع صوتي مرجعي لنقل هوية المتحدث. يُوصى بأن يكون حوالي ٥ ثوانٍ (مدة التدريب). المقاطع الأقصر أو الأطول قد تؤثر سلبًا على نقل هوية الصوت."
},
"start_percent": {
"name": "نسبة البداية",
"tooltip": "بداية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "إيجابي",
"tooltip": null
},
"2": {
"name": "سلبي",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXV المجدول",
"inputs": {
@@ -12110,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "تحويل صورة نقطية إلى SVG باستخدام Quiver AI.",
"display_name": "Quiver تحويل صورة إلى SVG",
"inputs": {
"auto_crop": {
"name": "auto_crop",
"tooltip": "قص تلقائي للعنصر الرئيسي في الصورة."
},
"control_after_generate": {
"name": "control after generate"
},
"image": {
"name": "image",
"tooltip": "الصورة المدخلة لتحويلها إلى متجهات."
},
"model": {
"name": "model",
"tooltip": "النموذج المستخدم لتحويل الصورة إلى SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "seed",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "إنشاء SVG من وصف نصي باستخدام Quiver AI.",
"display_name": "Quiver تحويل نص إلى SVG",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"instructions": {
"name": "instructions",
"tooltip": "إرشادات إضافية حول الأسلوب أو التنسيق."
},
"model": {
"name": "model",
"tooltip": "النموذج المستخدم لإنشاء SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "وصف نصي لمخرجات SVG المطلوبة."
},
"reference_images": {
"name": "reference_images",
"tooltip": "حتى ٤ صور مرجعية لتوجيه عملية الإنشاء."
},
"seed": {
"name": "seed",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
},
"tooltip": "مخصص: استبدال شريط عنوان النظام بالقائمة العلوية لـ ComfyUI"
},
"Comfy_Appearance_DisableAnimations": {
"name": "تعطيل الرسوم المتحركة",
"tooltip": "يقوم بإيقاف معظم الرسوم المتحركة والانتقالات في CSS. يسرّع الاستدلال عندما يتم استخدام وحدة معالجة الرسوميات للعرض أيضًا في التوليد."
},
"Comfy_Canvas_BackgroundImage": {
"name": "صورة خلفية اللوحة",
"tooltip": "رابط صورة لخلفية اللوحة. يمكنك النقر بزر الفأرة الأيمن على صورة في لوحة النتائج واختيار \"تعيين كخلفية\" لاستخدامها، أو رفع صورتك الخاصة باستخدام زر الرفع."
@@ -99,10 +95,6 @@
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "سرعة التحريك التلقائي",
"tooltip": "السرعة القصوى عند التحريك التلقائي بسحب المؤشر إلى حافة اللوحة. اضبطها على 0 لتعطيل التحريك التلقائي."
},
"Comfy_Graph_CanvasInfo": {
"name": "عرض معلومات اللوحة في الزاوية السفلى اليسرى (الإطارات في الثانية، إلخ)"
},
@@ -462,6 +454,9 @@
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "عرض تحذير النماذج المفقودة"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "عرض تحذير العقد المفقودة"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "ترتيب معرفات العقد عند حفظ سير العمل"
},

View File

@@ -34,8 +34,6 @@
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"viewGrid": "Grid view",
"imageGallery": "image gallery",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"previousImage": "Previous image",
@@ -278,7 +276,8 @@
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
@@ -1721,7 +1720,6 @@
"photomaker": "photomaker",
"PixVerse": "PixVerse",
"primitive": "primitive",
"Quiver": "Quiver",
"Recraft": "Recraft",
"edit_models": "edit_models",
"Reve": "Reve",
@@ -1766,7 +1764,6 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONING",
"CONTROL_NET": "CONTROL_NET",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -1780,7 +1777,6 @@
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
"GLIGEN": "GLIGEN",
"GUIDER": "GUIDER",
"HISTOGRAM": "HISTOGRAM",
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
"HOOKS": "HOOKS",
"IMAGE": "IMAGE",
@@ -1888,9 +1884,7 @@
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
"noStackTrace": "No stacktrace available",
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
"promptExecutionError": "Prompt execution failed"
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",
@@ -2295,12 +2289,7 @@
"topupTimeout": "Top-up verification timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
"personalWorkspace": "Personal Workspace",
"teamWorkspace": "Team Workspace",
"soloUseOnly": "Solo use only",
"needTeamWorkspace": "Need team workspace?",
"inviteUpTo": "Invite up to",
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
@@ -2612,18 +2601,6 @@
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"teamWorkspacesDialog": {
"title": "Team Workspaces",
"subtitle": "Switch to an existing one or create a new workspace",
"subtitleNoWorkspaces": "Create a new team workspace to share credits",
"confirmCallbackFailed": "Workspace created but setup incomplete",
"yourTeamWorkspaces": "Your team workspaces",
"switch": "Switch",
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
@@ -2631,8 +2608,7 @@
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
},
"selectionToolbox": {
"executeButton": {
@@ -3167,7 +3143,6 @@
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
@@ -3175,6 +3150,12 @@
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
"workflowDescription": "Workflow description",
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
"workflowType": "Workflow type",
"workflowTypePlaceholder": "Select the type",
"workflowTypeImageGeneration": "Image generation",
"workflowTypeVideoGeneration": "Video generation",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeEditing": "Editing",
"tags": "Tags",
"tagsDescription": "Select tags so people can find your workflow faster",
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
@@ -3201,17 +3182,11 @@
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"removeExampleImage": "Remove example image",
"exampleImage": "Example image {index}",
"exampleImagePosition": "Example image {index} of {total}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"shareAs": "Share as",
"additionalInfo": "Additional information",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
"createProfileCta": "Create a profile"
},
"comfyHubProfile": {
"checkingAccess": "Checking your publishing access...",
@@ -3230,7 +3205,6 @@
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"usernameError": "342 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",
@@ -3710,18 +3684,5 @@
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",
"loading": "Loading…",
"encoding": "Encoding…",
"decoding": "Decoding…",
"processing": "Processing…",
"resizing": "Resizing…",
"generatingVideo": "Generating video…",
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
}
}

View File

@@ -1438,22 +1438,6 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "Number Convert",
"inputs": {
"value": {
"name": "value"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
@@ -1783,14 +1767,6 @@
"freenoise": {
"name": "freenoise",
"tooltip": "Whether to apply FreeNoise noise shuffling, improves window blending."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "List of latent indices to retain in the conditioning tensors for each window, for example setting this to '0' will use the initial start image for each window."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."
}
},
"outputs": {
@@ -2234,23 +2210,6 @@
}
}
},
"CurveEditor": {
"display_name": "Curve Editor",
"inputs": {
"curve": {
"name": "curve"
},
"histogram": {
"name": "histogram"
}
},
"outputs": {
"0": {
"name": "curve",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "Custom Combo",
"inputs": {
@@ -3756,10 +3715,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "First image from the model's thinking process. Only available with thinking_level HIGH and IMAGE+TEXT modality."
}
}
},
@@ -4125,39 +4080,6 @@
}
}
},
"GrokVideoExtendNode": {
"display_name": "Grok Video Extend",
"description": "Extend an existing video with a seamless continuation based on a text prompt.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of what should happen next in the video."
},
"video": {
"name": "video",
"tooltip": "Source video to extend. MP4 format, 2-15 seconds."
},
"model": {
"name": "model",
"tooltip": "The model to use for video extension."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_duration": {
"name": "duration"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"display_name": "Grok Video",
"description": "Generate video from a prompt or an image",
@@ -4198,41 +4120,6 @@
}
}
},
"GrokVideoReferenceNode": {
"display_name": "Grok Reference-to-Video",
"description": "Generate video guided by reference images as style and content references.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired video."
},
"model": {
"name": "model",
"tooltip": "The model to use for video generation."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_duration": {
"name": "duration"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "Grow Mask",
"inputs": {
@@ -7724,54 +7611,6 @@
}
}
},
"LTXVReferenceAudio": {
"display_name": "LTXV Reference Audio (ID-LoRA)",
"description": "Set reference audio for ID-LoRA speaker identity transfer. Encodes a reference audio clip into the conditioning and optionally patches the model with identity guidance (extra forward pass without reference, amplifying the speaker identity effect).",
"inputs": {
"model": {
"name": "model"
},
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "Reference audio clip whose speaker identity to transfer. ~5 seconds recommended (training duration). Shorter or longer clips may degrade voice identity transfer."
},
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE for encoding."
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "Strength of identity guidance. Runs an extra forward pass without reference each step to amplify speaker identity. Set to 0 to disable (no extra pass)."
},
"start_percent": {
"name": "start_percent",
"tooltip": "Start of the sigma range where identity guidance is active."
},
"end_percent": {
"name": "end_percent",
"tooltip": "End of the sigma range where identity guidance is active."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positive",
"tooltip": null
},
"2": {
"name": "negative",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVScheduler",
"inputs": {
@@ -12110,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"display_name": "Quiver Image to SVG",
"description": "Vectorize a raster image into SVG using Quiver AI.",
"inputs": {
"image": {
"name": "image",
"tooltip": "Input image to vectorize."
},
"auto_crop": {
"name": "auto_crop",
"tooltip": "Automatically crop to the dominant subject."
},
"model": {
"name": "model",
"tooltip": "Model to use for SVG vectorization."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"display_name": "Quiver Text to SVG",
"description": "Generate an SVG from a text prompt using Quiver AI.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired SVG output."
},
"model": {
"name": "model",
"tooltip": "Model to use for SVG generation."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"instructions": {
"name": "instructions",
"tooltip": "Additional style or formatting guidance."
},
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 reference images to guide the generation."
},
"control_after_generate": {
"name": "control after generate"
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
"custom": "custom"
}
},
"Comfy_Appearance_DisableAnimations": {
"name": "Disable animations",
"tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
@@ -99,10 +95,6 @@
"name": "Float widget rounding decimal places [0 = auto].",
"tooltip": "(requires page reload)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_CanvasInfo": {
"name": "Show canvas info on bottom left corner (fps, etc.)"
},
@@ -128,6 +120,10 @@
"name": "Live selection",
"tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools."
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},

View File

@@ -15,14 +15,6 @@
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "Para la mejor experiencia, el constructor de aplicaciones utiliza Nodes 2.0. Puedes volver después de construir la aplicación desde el menú principal.",
"dismiss": "Descartar",
"dontShowAgain": "No mostrar de nuevo",
"title": "Cambiado a Nodes 2.0"
}
},
"assetBrowser": {
"allCategory": "Todo {category}",
"allModels": "Todos los modelos",
@@ -780,7 +772,6 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "ACONDICIONAMIENTO",
"CONTROL_NET": "RED_DE_CONTROL",
"CURVE": "CURVA",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "ARCHIVO_3D",
"FILE_3D_FBX": "ARCHIVO_3D_FBX",
@@ -794,7 +785,6 @@
"GEMINI_INPUT_FILES": "ARCHIVOS_ENTRADA_GEMINI",
"GLIGEN": "GLIGEN",
"GUIDER": "GUÍA",
"HISTOGRAM": "HISTOGRAMA",
"HOOKS": "GANCHOS",
"HOOK_KEYFRAMES": "GANCHO_FOTOGRAMAS_CLAVE",
"IMAGE": "IMAGEN",
@@ -886,8 +876,6 @@
"resume": "Reanudar descarga"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "Ocurrió un error",
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
@@ -936,19 +924,6 @@
"textToImage": "Texto a imagen",
"textToVideo": "Texto a video"
},
"execution": {
"decoding": "Decodificando…",
"encoding": "Codificando…",
"generating": "Generando…",
"generatingVideo": "Generando video…",
"loading": "Cargando…",
"processing": "Procesando…",
"processingVideo": "Procesando video…",
"resizing": "Redimensionando…",
"running": "Ejecutando…",
"saving": "Guardando…",
"training": "Entrenando…"
},
"exportToast": {
"allExportsCompleted": "Todas las exportaciones completadas",
"downloadExport": "Descargar exportación",
@@ -1089,14 +1064,11 @@
"filterBy": "Filtrar por:",
"filterByType": "Filtrar por {type}...",
"findIssues": "Encontrar problemas",
"findOnGithub": "Buscar en GitHub",
"frameNodes": "Enmarcar Nodos",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"gallery": "Galería",
"galleryImage": "Imagen de galería",
"galleryThumbnail": "Miniatura de galería",
"getHelpAction": "Obtener ayuda",
"goToNode": "Ir al nodo",
"graphNavigation": "Navegación de gráficos",
"halfSpeed": "0.5x",
@@ -1105,8 +1077,6 @@
"icon": "Icono",
"imageDoesNotExist": "La imagen no existe",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageGallery": "galería de imágenes",
"imageLightbox": "Vista previa de imagen",
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
"imageUrl": "URL de la imagen",
"import": "Importar",
@@ -1120,38 +1090,13 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemSelected": "{selectedCount} elemento seleccionado",
"itemsCopiedToClipboard": "Elementos copiados al portapapeles",
"itemsSelected": "{selectedCount} elementos seleccionados",
"job": "Tarea",
"jobIdCopied": "ID de trabajo copiado al portapapeles",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"keybindingPresets": {
"default": "Preajuste predeterminado",
"deletePreset": "Eliminar preajuste",
"deletePresetFailed": "No se pudo eliminar el preajuste \"{name}\"",
"deletePresetTitle": "¿Eliminar el preajuste actual?",
"deletePresetWarning": "Este preajuste será eliminado. Esto no se puede deshacer.",
"discardAndSwitch": "Descartar y cambiar",
"exportPreset": "Exportar preajuste",
"importKeybindingPreset": "Importar preajuste de atajos",
"importPreset": "Importar preajuste",
"invalidPresetFile": "El archivo de preajuste debe ser un JSON válido exportado desde ComfyUI",
"invalidPresetName": "El nombre del preajuste no debe estar vacío, ser \"default\", comenzar con un punto, contener separadores de ruta o terminar en .json",
"loadPresetFailed": "No se pudo cargar el preajuste \"{name}\"",
"overwritePresetMessage": "Ya existe un preajuste llamado \"{name}\". ¿Sobrescribirlo?",
"overwritePresetTitle": "Sobrescribir preajuste",
"presetDeleted": "Preajuste \"{name}\" eliminado",
"presetImported": "Preajuste de atajos importado",
"presetNamePrompt": "Introduce un nombre para el preajuste",
"presetSaved": "Preajuste \"{name}\" guardado",
"resetToDefault": "Restablecer a predeterminado",
"saveAndSwitch": "Guardar y cambiar",
"saveAsNewPreset": "Guardar como nuevo preajuste",
"saveChanges": "Guardar cambios",
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán si cambias sin guardar.",
"unsavedChangesTo": "Cambios no guardados en {name}"
},
"keybindings": "Atajos de teclado",
"learnMore": "Aprende más",
"listening": "Escuchando...",
@@ -1208,8 +1153,6 @@
"output": "Salida",
"overwrite": "Sobrescribir",
"partner": "Socio",
"pause": "Pausar",
"play": "Reproducir",
"playPause": "Reproducir/Pausar",
"playRecording": "Reproducir grabación",
"playbackSpeed": "Velocidad de reproducción",
@@ -1217,7 +1160,6 @@
"preloadError": "No se pudo cargar un recurso necesario. Por favor, recarga la página.",
"preloadErrorTitle": "Error de carga",
"preview": "VISTA PREVIA",
"previous": "Anterior",
"previousImage": "Imagen anterior",
"profile": "Perfil",
"progressCountOf": "de",
@@ -1292,8 +1234,6 @@
"showReport": "Mostrar informe",
"showRightPanel": "Mostrar panel derecho",
"singleSelectDropdown": "Menú desplegable de selección única",
"skipToEnd": "Ir al final",
"skipToStart": "Ir al inicio",
"sort": "Ordenar",
"source": "Fuente",
"startRecording": "Iniciar grabación",
@@ -1325,7 +1265,6 @@
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video",
"videoPreview": "Vista previa de video - Usa las teclas de flecha para navegar entre videos",
"viewGrid": "Vista de cuadrícula",
"viewImageOfTotal": "Ver imagen {index} de {total}",
"viewVideoOfTotal": "Ver video {index} de {total}",
"volume": "Volumen",
@@ -1893,7 +1832,6 @@
"mirrorVertical": "Espejar verticalmente",
"negative": "Negativo",
"opacity": "Opacidad",
"openMaskEditor": "Abrir en el editor de máscaras",
"paintBucketSettings": "Configuración del bote de pintura",
"paintLayer": "Capa de pintura",
"redo": "Rehacer",
@@ -2208,7 +2146,6 @@
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "Rodin",
@@ -2484,8 +2421,6 @@
"favoritesNoneDesc": "Las entradas que marques como favoritas aparecerán aquí",
"favoritesNoneHint": "En la pestaña Parámetros, haz clic en {moreIcon} en cualquier entrada para añadirla aquí",
"favoritesNoneTooltip": "Marca widgets con estrella para acceder rápidamente sin seleccionar nodos",
"findOnGithubTooltip": "Buscar problemas relacionados en GitHub",
"getHelpTooltip": "Informa de este error y te ayudaremos a resolverlo",
"globalSettings": {
"canvas": "LIENZO",
"connectionLinks": "ENLACES DE CONEXIÓN",
@@ -3138,6 +3073,7 @@
"title": "Tu suscripción ha sido cancelada"
},
"changeTo": "Cambiar a {plan}",
"chooseBestPlanWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo de Comfy Cloud",
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
@@ -3167,7 +3103,6 @@
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
"inviteUpTo": "Invita hasta",
"invoiceHistory": "Historial de facturas",
"learnMore": "Más información",
"managePayment": "Gestionar pago",
@@ -3193,16 +3128,13 @@
"monthlyCreditsPerMemberLabel": "Créditos mensuales / miembro",
"monthlyCreditsRollover": "Estos créditos se transferirán al próximo mes",
"mostPopular": "Más popular",
"needTeamWorkspace": "¿Necesitas un espacio de trabajo en equipo?",
"nextBillingCycle": "próximo ciclo de facturación",
"nextMonthInvoice": "Factura del próximo mes",
"partnerNodesBalance": "Saldo de créditos de \"Nodos de Partners\"",
"partnerNodesCredits": "Créditos de Nodos de Socio",
"partnerNodesDescription": "Para ejecutar modelos comerciales/propietarios",
"perMonth": "USD / mes",
"personalWorkspace": "Espacio de trabajo personal",
"plansAndPricing": "Planes y precios",
"plansForWorkspace": "Planes para {workspace}",
"prepaidCreditsInfo": "Créditos comprados por separado que no expiran",
"prepaidDescription": "Créditos prepagados",
"preview": {
@@ -3238,7 +3170,6 @@
"resubscribe": "Volver a suscribirse",
"resubscribeSuccess": "¡Suscripción reactivada correctamente!",
"resubscribeTo": "Volver a suscribirse a {plan}",
"soloUseOnly": "Solo para uso individual",
"subscribeForMore": "Mejorar",
"subscribeNow": "Suscribirse Ahora",
"subscribeTo": "Suscribirse a {plan}",
@@ -3246,7 +3177,6 @@
"subscribeToRun": "Suscribirse",
"subscribeToRunFull": "Suscribirse a Ejecutar",
"subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube",
"teamWorkspace": "Espacio de trabajo en equipo",
"tierNameYearly": "{name} Anual",
"tiers": {
"creator": {
@@ -3298,18 +3228,6 @@
"duplicateTab": "Duplicar pestaña",
"removeFromBookmarks": "Eliminar de marcadores"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "Espacio de trabajo creado pero la configuración está incompleta",
"createWorkspace": "Crear espacio de trabajo",
"namePlaceholder": "ej. Equipo de Marketing",
"nameValidationError": "El nombre debe tener entre 1 y 50 caracteres usando letras, números, espacios o signos de puntuación comunes.",
"newWorkspace": "Nuevo espacio de trabajo",
"subtitle": "Cambia a uno existente o crea un nuevo espacio de trabajo",
"subtitleNoWorkspaces": "Crea un nuevo espacio de trabajo en equipo para compartir créditos",
"switch": "Cambiar",
"title": "Espacios de trabajo en equipo",
"yourTeamWorkspaces": "Tus espacios de trabajo en equipo"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "Buscar..."
@@ -3696,7 +3614,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "Crear nuevo espacio de trabajo",
"failedToSwitch": "No se pudo cambiar el espacio de trabajo",
"maxWorkspacesReached": "Solo puedes ser propietario de 10 espacios de trabajo. Elimina uno para crear uno nuevo.",
"personal": "Personal",
"roleMember": "Miembro",

View File

@@ -1438,22 +1438,6 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "Conversión de número",
"inputs": {
"value": {
"name": "valor"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Interruptor",
"inputs": {
@@ -1752,10 +1736,6 @@
"name": "bucle_cerrado",
"tooltip": "Si se debe cerrar el bucle de la ventana de contexto; solo aplicable a programaciones en bucle."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "Lista de índices latentes que se conservarán en los tensores de condicionamiento para cada ventana; por ejemplo, si se establece en '0', se usará la imagen inicial de inicio para cada ventana."
},
"context_length": {
"name": "longitud_contexto",
"tooltip": "La longitud de la ventana de contexto."
@@ -1787,10 +1767,6 @@
"model": {
"name": "modelo",
"tooltip": "El modelo al que aplicar ventanas de contexto durante el muestreo."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "Indica si se deben dividir múltiples condicionamientos (creados por ConditionCombine) en cada ventana según el índice de la región."
}
},
"outputs": {
@@ -2234,23 +2210,6 @@
}
}
},
"CurveEditor": {
"display_name": "Editor de curvas",
"inputs": {
"curve": {
"name": "curva"
},
"histogram": {
"name": "histograma"
}
},
"outputs": {
"0": {
"name": "curva",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "Combinación personalizada",
"inputs": {
@@ -3856,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "Primera imagen del proceso de pensamiento del modelo. Solo disponible con nivel de pensamiento ALTO y modalidad IMAGEN+TEXTO."
}
}
},
@@ -4125,39 +4080,6 @@
}
}
},
"GrokVideoExtendNode": {
"description": "Extiende un video existente con una continuación fluida basada en un prompt de texto.",
"display_name": "Extender video Grok",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la extensión de video."
},
"model_duration": {
"name": "duración"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto de lo que debe suceder a continuación en el video."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
},
"video": {
"name": "video",
"tooltip": "Video fuente a extender. Formato MP4, 2-15 segundos."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "Genera video a partir de una indicación o una imagen",
"display_name": "Video Grok",
@@ -4198,41 +4120,6 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "Genera video guiado por imágenes de referencia como referencias de estilo y contenido.",
"display_name": "Referencia a video Grok",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"model": {
"name": "modelo",
"tooltip": "El modelo a utilizar para la generación de video."
},
"model_aspect_ratio": {
"name": "relación de aspecto"
},
"model_duration": {
"name": "duración"
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto del video deseado."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "GrowMask",
"inputs": {
@@ -6849,54 +6736,6 @@
}
}
},
"LTXVReferenceAudio": {
"description": "Establece el audio de referencia para la transferencia de identidad de locutor con ID-LoRA. Codifica un clip de audio de referencia en el condicionamiento y, opcionalmente, modifica el modelo con una guía de identidad (pase adicional sin referencia, amplificando el efecto de identidad del locutor).",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE para la codificación."
},
"end_percent": {
"name": "porcentaje_fin",
"tooltip": "Fin del rango sigma donde la guía de identidad está activa."
},
"identity_guidance_scale": {
"name": "escala_guía_identidad",
"tooltip": "Intensidad de la guía de identidad. Ejecuta un pase adicional sin referencia en cada paso para amplificar la identidad del locutor. Establece en 0 para desactivar (sin pase adicional)."
},
"model": {
"name": "modelo"
},
"negative": {
"name": "negativo"
},
"positive": {
"name": "positivo"
},
"reference_audio": {
"name": "audio_referencia",
"tooltip": "Clip de audio de referencia cuya identidad de locutor se va a transferir. Se recomienda ~5 segundos (duración de entrenamiento). Clips más cortos o largos pueden degradar la transferencia de identidad de voz."
},
"start_percent": {
"name": "porcentaje_inicio",
"tooltip": "Inicio del rango sigma donde la guía de identidad está activa."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "positivo",
"tooltip": null
},
"2": {
"name": "negativo",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVProgramador",
"inputs": {
@@ -12110,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "Vectoriza una imagen ráster a SVG usando Quiver AI.",
"display_name": "Quiver Imagen a SVG",
"inputs": {
"auto_crop": {
"name": "recorte_automático",
"tooltip": "Recorta automáticamente al sujeto dominante."
},
"control_after_generate": {
"name": "control después de generar"
},
"image": {
"name": "imagen",
"tooltip": "Imagen de entrada para vectorizar."
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la vectorización SVG."
},
"model_presence_penalty": {
"name": "penalización_de_presencia"
},
"model_target_size": {
"name": "tamaño_objetivo"
},
"model_temperature": {
"name": "temperatura"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "Genera un SVG a partir de un prompt de texto usando Quiver AI.",
"display_name": "Quiver Texto a SVG",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"instructions": {
"name": "instrucciones",
"tooltip": "Guía adicional de estilo o formato."
},
"model": {
"name": "modelo",
"tooltip": "Modelo a utilizar para la generación de SVG."
},
"model_presence_penalty": {
"name": "penalización_de_presencia"
},
"model_temperature": {
"name": "temperatura"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "Descripción en texto del SVG deseado."
},
"reference_images": {
"name": "imágenes_de_referencia",
"tooltip": "Hasta 4 imágenes de referencia para guiar la generación."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
},
"tooltip": "Personalizado: Reemplace la barra de título del sistema con el menú superior de ComfyUI"
},
"Comfy_Appearance_DisableAnimations": {
"name": "Desactivar animaciones",
"tooltip": "Desactiva la mayoría de las animaciones y transiciones CSS. Acelera la inferencia cuando la GPU de pantalla también se utiliza para la generación."
},
"Comfy_Canvas_BackgroundImage": {
"name": "Imagen de fondo del lienzo",
"tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla."
@@ -99,10 +95,6 @@
"name": "Decimales de redondeo del widget flotante [0 = automático].",
"tooltip": "(requiere recargar la página)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Velocidad de auto-desplazamiento",
"tooltip": "Velocidad máxima al auto-desplazar arrastrando hacia el borde del lienzo. Establece en 0 para desactivar el auto-desplazamiento."
},
"Comfy_Graph_CanvasInfo": {
"name": "Mostrar información del lienzo en la esquina inferior izquierda (fps, etc.)"
},
@@ -462,6 +454,9 @@
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "Mostrar advertencia de modelos faltantes"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "Mostrar advertencia de nodos faltantes"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "Ordenar IDs de nodos al guardar el flujo de trabajo"
},

View File

@@ -15,14 +15,6 @@
"message": "این workflow شامل API Node است که برای اجرا نیاز به ورود به حساب کاربری دارد.",
"title": "ورود برای استفاده از API Nodeها لازم است"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "برای بهترین تجربه، سازنده اپلیکیشن از Nodes 2.0 استفاده می‌کند. پس از ساخت اپلیکیشن می‌توانید از منوی اصلی به نسخه قبلی بازگردید.",
"dismiss": "بستن",
"dontShowAgain": "دیگر نمایش نده",
"title": "به Nodes 2.0 منتقل شدید"
}
},
"assetBrowser": {
"allCategory": "همه {category}",
"allModels": "همه مدل‌ها",
@@ -780,7 +772,6 @@
"COMFY_MATCHTYPE_V3": "Comfy MatchType V3",
"CONDITIONING": "شرط‌گذاری",
"CONTROL_NET": "controlnet",
"CURVE": "CURVE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FILE_3D",
"FILE_3D_FBX": "FILE_3D_FBX",
@@ -794,7 +785,6 @@
"GEMINI_INPUT_FILES": "فایل‌های ورودی Gemini",
"GLIGEN": "GLIGEN",
"GUIDER": "راهنما",
"HISTOGRAM": "HISTOGRAM",
"HOOKS": "hookها",
"HOOK_KEYFRAMES": "کلیدفریم‌های hook",
"IMAGE": "تصویر",
@@ -886,8 +876,6 @@
"resume": "ادامه دانلود"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "خطایی رخ داد",
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
@@ -936,19 +924,6 @@
"textToImage": "تبدیل متن به تصویر",
"textToVideo": "تبدیل متن به ویدیو"
},
"execution": {
"decoding": "در حال رمزگشایی…",
"encoding": "در حال کدگذاری…",
"generating": "در حال تولید…",
"generatingVideo": "در حال تولید ویدیو…",
"loading": "در حال بارگذاری…",
"processing": "در حال پردازش…",
"processingVideo": "در حال پردازش ویدیو…",
"resizing": "در حال تغییر اندازه…",
"running": "در حال اجرا…",
"saving": "در حال ذخیره‌سازی…",
"training": "در حال آموزش…"
},
"exportToast": {
"allExportsCompleted": "همه خروجی‌ها تکمیل شد",
"downloadExport": "دانلود خروجی",
@@ -1089,14 +1064,11 @@
"filterBy": "فیلتر بر اساس:",
"filterByType": "فیلتر بر اساس {type}...",
"findIssues": "یافتن مشکلات",
"findOnGithub": "یافتن در GitHub",
"frameNodes": "قاب‌بندی nodeها",
"frontendNewer": "نسخه فرانت‌اند {frontendVersion} ممکن است با نسخه بک‌اند {backendVersion} ناسازگار باشد.",
"frontendOutdated": "نسخه فرانت‌اند {frontendVersion} قدیمی است. بک‌اند به نسخه {requiredVersion} یا بالاتر نیاز دارد.",
"gallery": "گالری",
"galleryImage": "تصویر گالری",
"galleryThumbnail": "تصویر بندانگشتی گالری",
"getHelpAction": "دریافت راهنما",
"goToNode": "رفتن به node",
"graphNavigation": "ناوبری گراف",
"halfSpeed": "۰.۵x",
@@ -1105,8 +1077,6 @@
"icon": "آیکون",
"imageDoesNotExist": "تصویر وجود ندارد",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imageGallery": "گالری تصاویر",
"imageLightbox": "پیش‌نمایش تصویر",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
"imageUrl": "آدرس تصویر",
"import": "وارد کردن",
@@ -1120,38 +1090,13 @@
"installed": "نصب شده",
"installing": "در حال نصب",
"interrupted": "متوقف شده",
"itemSelected": "{selectedCount} مورد انتخاب شد",
"itemsCopiedToClipboard": "موارد در کلیپ‌بورد کپی شدند",
"itemsSelected": "{selectedCount} مورد انتخاب شدند",
"job": "وظیفه",
"jobIdCopied": "شناسه وظیفه در کلیپ‌بورد کپی شد",
"keybinding": "کلید میانبر",
"keybindingAlreadyExists": "کلید میانبر قبلاً وجود دارد در",
"keybindingPresets": {
"default": "پیش‌تنظیم پیش‌فرض",
"deletePreset": "حذف پیش‌تنظیم",
"deletePresetFailed": "حذف پیش‌تنظیم «{name}» ناموفق بود",
"deletePresetTitle": "پیش‌تنظیم فعلی حذف شود؟",
"deletePresetWarning": "این پیش‌تنظیم حذف خواهد شد. این عمل قابل بازگشت نیست.",
"discardAndSwitch": "رد کردن و جابجایی",
"exportPreset": "خروجی گرفتن از پیش‌تنظیم",
"importKeybindingPreset": "وارد کردن پیش‌تنظیم کلیدها",
"importPreset": "وارد کردن پیش‌تنظیم",
"invalidPresetFile": "فایل پیش‌تنظیم باید یک JSON معتبر باشد که از ComfyUI خروجی گرفته شده است",
"invalidPresetName": "نام پیش‌تنظیم نباید خالی، «default»، با نقطه شروع شود، شامل جداکننده مسیر باشد یا با .json پایان یابد",
"loadPresetFailed": "بارگذاری پیش‌تنظیم «{name}» ناموفق بود",
"overwritePresetMessage": "پیش‌تنظیمی با نام «{name}» وجود دارد. جایگزین شود؟",
"overwritePresetTitle": "جایگزینی پیش‌تنظیم",
"presetDeleted": "پیش‌تنظیم «{name}» حذف شد",
"presetImported": "پیش‌تنظیم کلیدها وارد شد",
"presetNamePrompt": "یک نام برای پیش‌تنظیم وارد کنید",
"presetSaved": "پیش‌تنظیم «{name}» ذخیره شد",
"resetToDefault": "بازنشانی به پیش‌فرض",
"saveAndSwitch": "ذخیره و جابجایی",
"saveAsNewPreset": "ذخیره به عنوان پیش‌تنظیم جدید",
"saveChanges": "ذخیره تغییرات",
"unsavedChangesMessage": "تغییرات ذخیره‌نشده‌ای دارید که در صورت جابجایی بدون ذخیره، از بین خواهند رفت.",
"unsavedChangesTo": "تغییرات ذخیره‌نشده برای {name}"
},
"keybindings": "کلیدهای میانبر",
"learnMore": "اطلاعات بیشتر",
"listening": "در حال گوش دادن...",
@@ -1208,8 +1153,6 @@
"output": "خروجی",
"overwrite": "جایگزینی",
"partner": "همکار",
"pause": "توقف",
"play": "پخش",
"playPause": "پخش/توقف",
"playRecording": "پخش ضبط",
"playbackSpeed": "سرعت پخش",
@@ -1217,7 +1160,6 @@
"preloadError": "یک منبع مورد نیاز بارگذاری نشد. لطفاً صفحه را مجدداً بارگذاری کنید.",
"preloadErrorTitle": "خطا در بارگذاری",
"preview": "پیش‌نمایش",
"previous": "قبلی",
"previousImage": "تصویر قبلی",
"profile": "پروفایل",
"progressCountOf": "از",
@@ -1292,8 +1234,6 @@
"showReport": "نمایش گزارش",
"showRightPanel": "نمایش پنل راست",
"singleSelectDropdown": "لیست کشویی تک‌انتخابی",
"skipToEnd": "رفتن به انتها",
"skipToStart": "رفتن به ابتدا",
"sort": "مرتب‌سازی",
"source": "منبع",
"startRecording": "شروع ضبط",
@@ -1325,7 +1265,6 @@
"versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.",
"videoFailedToLoad": "بارگذاری ویدیو ناموفق بود",
"videoPreview": "پیش‌نمایش ویدیو - برای جابجایی بین ویدیوها از کلیدهای جهت‌دار استفاده کنید",
"viewGrid": "نمای شبکه‌ای",
"viewImageOfTotal": "مشاهده تصویر {index} از {total}",
"viewVideoOfTotal": "مشاهده ویدیو {index} از {total}",
"volume": "حجم صدا",
@@ -1893,7 +1832,6 @@
"mirrorVertical": "آینه عمودی",
"negative": "نگاتیو",
"opacity": "شفافیت",
"openMaskEditor": "باز کردن در Mask Editor",
"paintBucketSettings": "تنظیمات سطل رنگ",
"paintLayer": "لایه نقاشی",
"redo": "بازانجام",
@@ -2208,7 +2146,6 @@
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "Rodin",
@@ -2484,8 +2421,6 @@
"favoritesNoneDesc": "ورودی‌هایی که به علاقه‌مندی‌ها اضافه کنید اینجا نمایش داده می‌شوند",
"favoritesNoneHint": "در تب پارامترها، روی {moreIcon} هر ورودی کلیک کنید تا اینجا اضافه شود",
"favoritesNoneTooltip": "برای دسترسی سریع، ویجت‌ها را ستاره‌دار کنید تا بدون انتخاب nodeها به آن‌ها دسترسی داشته باشید",
"findOnGithubTooltip": "جستجوی مشکلات مرتبط در GitHub",
"getHelpTooltip": "گزارش این خطا و دریافت راهنمایی برای رفع آن",
"globalSettings": {
"canvas": "canvas",
"connectionLinks": "اتصالات",
@@ -3150,6 +3085,7 @@
"title": "اشتراک شما لغو شده است"
},
"changeTo": "تغییر به {plan}",
"chooseBestPlanWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "لوگوی Comfy Cloud",
"contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید",
@@ -3179,7 +3115,6 @@
},
"gpuLabel": "RTX 6000 Pro (۹۶ گیگابایت VRAM)",
"haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟",
"inviteUpTo": "دعوت تا سقف",
"invoiceHistory": "تاریخچه فاکتورها",
"learnMore": "اطلاعات بیشتر",
"managePayment": "مدیریت پرداخت",
@@ -3205,16 +3140,13 @@
"monthlyCreditsPerMemberLabel": "اعتبار ماهانه / هر عضو",
"monthlyCreditsRollover": "این اعتبارها به ماه بعد منتقل می‌شوند",
"mostPopular": "محبوب‌ترین",
"needTeamWorkspace": "به فضای کاری تیمی نیاز دارید؟",
"nextBillingCycle": "چرخه صورتحساب بعدی",
"nextMonthInvoice": "صورتحساب ماه آینده",
"partnerNodesBalance": "اعتبار «Partner Nodes»",
"partnerNodesCredits": "قیمت‌گذاری Partner Nodes",
"partnerNodesDescription": "برای اجرای مدل‌های تجاری/اختصاصی",
"perMonth": "/ ماه",
"personalWorkspace": "فضای کاری شخصی",
"plansAndPricing": "طرح‌ها و قیمت‌ها",
"plansForWorkspace": "طرح‌ها برای {workspace}",
"prepaidCreditsInfo": "اعتبارهای پیش‌پرداخت تا یک سال پس از تاریخ خرید منقضی می‌شوند.",
"prepaidDescription": "اعتبارهای پیش‌پرداخت",
"preview": {
@@ -3250,7 +3182,6 @@
"resubscribe": "تمدید اشتراک",
"resubscribeSuccess": "اشتراک با موفقیت فعال شد",
"resubscribeTo": "تمدید اشتراک {plan}",
"soloUseOnly": "فقط برای استفاده فردی",
"subscribeForMore": "ارتقاء",
"subscribeNow": "هم‌اکنون اشتراک بگیرید",
"subscribeTo": "اشتراک در {plan}",
@@ -3258,7 +3189,6 @@
"subscribeToRun": "اشتراک",
"subscribeToRunFull": "اشتراک برای اجرا",
"subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.",
"teamWorkspace": "فضای کاری تیمی",
"tierNameYearly": "{name} سالانه",
"tiers": {
"creator": {
@@ -3310,18 +3240,6 @@
"duplicateTab": "ایجاد تب مشابه",
"removeFromBookmarks": "حذف از نشانک‌ها"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "فضای کاری ایجاد شد اما راه‌اندازی کامل نشد",
"createWorkspace": "ایجاد فضای کاری",
"namePlaceholder": "مثلاً تیم بازاریابی",
"nameValidationError": "نام باید بین ۱ تا ۵۰ کاراکتر و شامل حروف، اعداد، فاصله یا علائم نگارشی رایج باشد.",
"newWorkspace": "فضای کاری جدید",
"subtitle": "به یکی از فضاهای موجود بروید یا فضای کاری جدیدی ایجاد کنید",
"subtitleNoWorkspaces": "برای اشتراک‌گذاری اعتبارها، فضای کاری تیمی جدیدی ایجاد کنید",
"switch": "تغییر",
"title": "فضاهای کاری تیمی",
"yourTeamWorkspaces": "فضاهای کاری تیمی شما"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "جستجو..."
@@ -3708,7 +3626,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "ایجاد محیط کاری جدید",
"failedToSwitch": "تغییر فضای کاری ناموفق بود",
"maxWorkspacesReached": "شما فقط می‌توانید مالک ۱۰ محیط کاری باشید. برای ایجاد محیط کاری جدید، یکی را حذف کنید.",
"personal": "شخصی",
"roleMember": "عضو",

View File

@@ -1438,22 +1438,6 @@
}
}
},
"ComfyNumberConvert": {
"display_name": "تبدیل عدد",
"inputs": {
"value": {
"name": "مقدار"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "سوئیچ",
"inputs": {
@@ -1752,10 +1736,6 @@
"name": "حلقه بسته",
"tooltip": "آیا حلقه پنجره زمینه بسته شود؛ فقط برای برنامه‌ریزی حلقه‌ای قابل استفاده است."
},
"cond_retain_index_list": {
"name": "cond_retain_index_list",
"tooltip": "فهرست اندیس‌های latent که باید در تنسورهای شرطی برای هر پنجره حفظ شوند؛ برای مثال، اگر این مقدار را '۰' قرار دهید، تصویر ابتدایی برای هر پنجره استفاده خواهد شد."
},
"context_length": {
"name": "طول پنجره زمینه",
"tooltip": "طول پنجره زمینه."
@@ -1787,10 +1767,6 @@
"model": {
"name": "مدل",
"tooltip": "مدلی که پنجره‌های زمینه هنگام نمونه‌گیری بر آن اعمال می‌شود."
},
"split_conds_to_windows": {
"name": "split_conds_to_windows",
"tooltip": "آیا شرط‌های متعدد (ایجاد شده توسط ConditionCombine) بر اساس اندیس ناحیه به هر پنجره تقسیم شوند یا خیر."
}
},
"outputs": {
@@ -2234,23 +2210,6 @@
}
}
},
"CurveEditor": {
"display_name": "ویرایشگر منحنی",
"inputs": {
"curve": {
"name": "منحنی"
},
"histogram": {
"name": "هیستوگرام"
}
},
"outputs": {
"0": {
"name": "منحنی",
"tooltip": null
}
}
},
"CustomCombo": {
"display_name": "ترکیب سفارشی",
"inputs": {
@@ -3856,10 +3815,6 @@
},
"1": {
"tooltip": null
},
"2": {
"name": "thought_image",
"tooltip": "اولین تصویر از فرایند تفکر مدل. فقط در حالت thinking_level بالا و مدالیته IMAGE+TEXT در دسترس است."
}
}
},
@@ -4125,39 +4080,6 @@
}
}
},
"GrokVideoExtendNode": {
"description": "گسترش یک ویدیوی موجود با ادامه‌ای یکپارچه بر اساس یک متن راهنما.",
"display_name": "گسترش ویدیو Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای گسترش ویدیو استفاده می‌شود."
},
"model_duration": {
"name": "مدت زمان"
},
"prompt": {
"name": "راهنمای متنی",
"tooltip": "توضیح متنی درباره آنچه باید در ادامه ویدیو رخ دهد."
},
"seed": {
"name": "seed",
"tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
},
"video": {
"name": "ویدیو",
"tooltip": "ویدیوی منبع برای گسترش. فرمت MP4، بین ۲ تا ۱۵ ثانیه."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "تولید ویدیو از یک راهنما یا تصویر",
"display_name": "ویدیو Grok",
@@ -4198,41 +4120,6 @@
}
}
},
"GrokVideoReferenceNode": {
"description": "تولید ویدیو با راهنمایی تصاویر مرجع به عنوان سبک و محتوا.",
"display_name": "تولید ویدیو با مرجع Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل",
"tooltip": "مدلی که برای تولید ویدیو استفاده می‌شود."
},
"model_aspect_ratio": {
"name": "نسبت تصویر"
},
"model_duration": {
"name": "مدت زمان"
},
"model_resolution": {
"name": "وضوح"
},
"prompt": {
"name": "راهنمای متنی",
"tooltip": "توضیح متنی درباره ویدیوی مورد نظر."
},
"seed": {
"name": "seed",
"tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "گسترش ماسک",
"inputs": {
@@ -6849,54 +6736,6 @@
}
}
},
"LTXVReferenceAudio": {
"description": "تنظیم صدای مرجع برای انتقال هویت گوینده با استفاده از ID-LoRA. یک کلیپ صوتی مرجع را به صورت شرطی رمزگذاری می‌کند و در صورت نیاز مدل را با راهنمایی هویتی (یک عبور اضافی بدون مرجع برای تقویت اثر هویت گوینده) اصلاح می‌کند.",
"display_name": "LTXV Reference Audio (ID-LoRA)",
"inputs": {
"audio_vae": {
"name": "audio_vae",
"tooltip": "LTXV Audio VAE برای رمزگذاری."
},
"end_percent": {
"name": "end_percent",
"tooltip": "پایان بازه سیگما که راهنمایی هویتی در آن فعال است."
},
"identity_guidance_scale": {
"name": "identity_guidance_scale",
"tooltip": "شدت راهنمایی هویتی. در هر مرحله یک عبور اضافی بدون مرجع اجرا می‌شود تا هویت گوینده تقویت شود. برای غیرفعال کردن، مقدار را روی ۰ قرار دهید (بدون عبور اضافی)."
},
"model": {
"name": "مدل"
},
"negative": {
"name": "منفی"
},
"positive": {
"name": "مثبت"
},
"reference_audio": {
"name": "reference_audio",
"tooltip": "کلیپ صوتی مرجع که هویت گوینده آن منتقل می‌شود. مدت زمان پیشنهادی حدود ۵ ثانیه (مدت زمان آموزش). کلیپ‌های کوتاه‌تر یا بلندتر ممکن است کیفیت انتقال هویت صدا را کاهش دهند."
},
"start_percent": {
"name": "start_percent",
"tooltip": "آغاز بازه سیگما که راهنمایی هویتی در آن فعال است."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "مثبت",
"tooltip": null
},
"2": {
"name": "منفی",
"tooltip": null
}
}
},
"LTXVScheduler": {
"display_name": "LTXVScheduler",
"inputs": {
@@ -12110,91 +11949,6 @@
}
}
},
"QuiverImageToSVGNode": {
"description": "بردارسازی یک تصویر شطرنجی به SVG با استفاده از Quiver AI.",
"display_name": "تبدیل تصویر Quiver به SVG",
"inputs": {
"auto_crop": {
"name": "auto_crop",
"tooltip": "برش خودکار به سوژه غالب."
},
"control_after_generate": {
"name": "control after generate"
},
"image": {
"name": "image",
"tooltip": "تصویر ورودی برای بردارسازی."
},
"model": {
"name": "model",
"tooltip": "مدل مورد استفاده برای بردارسازی SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_target_size": {
"name": "target_size"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QuiverTextToSVGNode": {
"description": "تولید یک SVG از طریق پرامپت متنی با استفاده از Quiver AI.",
"display_name": "تبدیل متن Quiver به SVG",
"inputs": {
"control_after_generate": {
"name": "control after generate"
},
"instructions": {
"name": "instructions",
"tooltip": "راهنمایی‌های اضافی برای سبک یا قالب‌بندی."
},
"model": {
"name": "model",
"tooltip": "مدل مورد استفاده برای تولید SVG."
},
"model_presence_penalty": {
"name": "presence_penalty"
},
"model_temperature": {
"name": "temperature"
},
"model_top_p": {
"name": "top_p"
},
"prompt": {
"name": "prompt",
"tooltip": "توضیح متنی برای خروجی SVG مورد نظر."
},
"reference_images": {
"name": "reference_images",
"tooltip": "حداکثر ۴ تصویر مرجع برای راهنمایی تولید."
},
"seed": {
"name": "seed",
"tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"QwenImageDiffsynthControlnet": {
"display_name": "QwenImageDiffsynthControlnet",
"inputs": {

View File

@@ -25,10 +25,6 @@
},
"tooltip": "سفارشی: نوار عنوان سیستم با منوی بالای ComfyUI جایگزین می‌شود"
},
"Comfy_Appearance_DisableAnimations": {
"name": "غیرفعال‌سازی انیمیشن‌ها",
"tooltip": "بیشتر انیمیشن‌ها و انتقال‌های CSS را غیرفعال می‌کند. زمانی که GPU نمایش برای تولید نیز استفاده می‌شود، سرعت استنتاج را افزایش می‌دهد."
},
"Comfy_Canvas_BackgroundImage": {
"name": "تصویر پس‌زمینه بوم",
"tooltip": "آدرس تصویر برای پس‌زمینه بوم. می‌توانید روی یک تصویر در پانل خروجی راست‌کلیک کرده و «تنظیم به عنوان پس‌زمینه» را انتخاب کنید یا تصویر دلخواه خود را با دکمه بارگذاری، بارگذاری نمایید."
@@ -99,10 +95,6 @@
"name": "تعداد ارقام اعشاری گرد کردن ابزارک اعشاری [۰ = خودکار].",
"tooltip": "(نیاز به بارگذاری مجدد صفحه)"
},
"Comfy_Graph_AutoPanSpeed": {
"name": "سرعت حرکت خودکار",
"tooltip": "حداکثر سرعت هنگام حرکت خودکار با کشیدن به لبه بوم. برای غیرفعال‌سازی حرکت خودکار، مقدار را روی ۰ قرار دهید."
},
"Comfy_Graph_CanvasInfo": {
"name": "نمایش اطلاعات بوم در گوشه پایین سمت چپ (fps و غیره)"
},
@@ -462,6 +454,9 @@
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "نمایش هشدار مدل‌های مفقود"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "نمایش هشدار نودهای مفقود"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "مرتب‌سازی شناسه نودها هنگام ذخیره ورک‌فلو"
},

View File

@@ -15,14 +15,6 @@
"message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.",
"title": "Connexion requise pour utiliser les nœuds API"
},
"appBuilder": {
"vueNodeSwitch": {
"content": "Pour une expérience optimale, le constructeur d'applications utilise Nodes 2.0. Vous pouvez revenir en arrière après avoir construit l'application depuis le menu principal.",
"dismiss": "Fermer",
"dontShowAgain": "Ne plus afficher",
"title": "Passage à Nodes 2.0"
}
},
"assetBrowser": {
"allCategory": "Tous les {category}",
"allModels": "Tous les modèles",
@@ -780,7 +772,6 @@
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONNEMENT",
"CONTROL_NET": "RESEAU_DE_CONTROLE",
"CURVE": "COURBE",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FILE_3D": "FICHIER_3D",
"FILE_3D_FBX": "FICHIER_3D_FBX",
@@ -794,7 +785,6 @@
"GEMINI_INPUT_FILES": "FICHIERS_ENTRÉE_GEMINI",
"GLIGEN": "GLIGEN",
"GUIDER": "GUIDE",
"HISTOGRAM": "HISTOGRAMME",
"HOOKS": "CROCHETS",
"HOOK_KEYFRAMES": "CLEFS_DE_CROCHET",
"IMAGE": "IMAGE",
@@ -886,8 +876,6 @@
"resume": "Reprendre le téléchargement"
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
"defaultTitle": "Une erreur est survenue",
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
@@ -936,19 +924,6 @@
"textToImage": "Texte vers image",
"textToVideo": "Texte vers vidéo"
},
"execution": {
"decoding": "Décodage…",
"encoding": "Encodage…",
"generating": "Génération…",
"generatingVideo": "Génération de la vidéo…",
"loading": "Chargement…",
"processing": "Traitement…",
"processingVideo": "Traitement de la vidéo…",
"resizing": "Redimensionnement…",
"running": "Exécution…",
"saving": "Enregistrement…",
"training": "Entraînement…"
},
"exportToast": {
"allExportsCompleted": "Toutes les exportations sont terminées",
"downloadExport": "Télécharger lexport",
@@ -1089,14 +1064,11 @@
"filterBy": "Filtrer par :",
"filterByType": "Filtrer par {type}...",
"findIssues": "Trouver des problèmes",
"findOnGithub": "Trouver sur GitHub",
"frameNodes": "Encadrer les nœuds",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.",
"gallery": "Galerie",
"galleryImage": "Image de la galerie",
"galleryThumbnail": "Miniature de la galerie",
"getHelpAction": "Obtenir de l'aide",
"goToNode": "Aller au nœud",
"graphNavigation": "Navigation dans le graphe",
"halfSpeed": "0.5x",
@@ -1105,8 +1077,6 @@
"icon": "Icône",
"imageDoesNotExist": "Limage nexiste pas",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageGallery": "galerie dimages",
"imageLightbox": "Aperçu de l'image",
"imagePreview": "Aperçu de l'image - Utilisez les flèches pour naviguer entre les images",
"imageUrl": "URL de l'image",
"import": "Importer",
@@ -1120,38 +1090,13 @@
"installed": "Installé",
"installing": "Installation",
"interrupted": "Interrompu",
"itemSelected": "{selectedCount} élément sélectionné",
"itemsCopiedToClipboard": "Éléments copiés dans le presse-papiers",
"itemsSelected": "{selectedCount} éléments sélectionnés",
"job": "Tâche",
"jobIdCopied": "ID du travail copié dans le presse-papiers",
"keybinding": "Raccourci clavier",
"keybindingAlreadyExists": "Le raccourci clavier existe déjà",
"keybindingPresets": {
"default": "Préréglage par défaut",
"deletePreset": "Supprimer le préréglage",
"deletePresetFailed": "Échec de la suppression du préréglage \"{name}\"",
"deletePresetTitle": "Supprimer le préréglage actuel ?",
"deletePresetWarning": "Ce préréglage sera supprimé. Cette action est irréversible.",
"discardAndSwitch": "Ignorer et changer",
"exportPreset": "Exporter le préréglage",
"importKeybindingPreset": "Importer un préréglage de raccourcis",
"importPreset": "Importer un préréglage",
"invalidPresetFile": "Le fichier de préréglage doit être un JSON valide exporté depuis ComfyUI",
"invalidPresetName": "Le nom du préréglage ne doit pas être vide, \"default\", commencer par un point, contenir des séparateurs de chemin ou se terminer par .json",
"loadPresetFailed": "Échec du chargement du préréglage \"{name}\"",
"overwritePresetMessage": "Un préréglage nommé \"{name}\" existe déjà. Voulez-vous l'écraser ?",
"overwritePresetTitle": "Écraser le préréglage",
"presetDeleted": "Préréglage \"{name}\" supprimé",
"presetImported": "Préréglage de raccourcis importé",
"presetNamePrompt": "Entrez un nom pour le préréglage",
"presetSaved": "Préréglage \"{name}\" enregistré",
"resetToDefault": "Réinitialiser par défaut",
"saveAndSwitch": "Enregistrer et changer",
"saveAsNewPreset": "Enregistrer comme nouveau préréglage",
"saveChanges": "Enregistrer les modifications",
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous changez sans enregistrer.",
"unsavedChangesTo": "Modifications non enregistrées pour {name}"
},
"keybindings": "Raccourcis clavier",
"learnMore": "En savoir plus",
"listening": "Écoute en cours...",
@@ -1208,8 +1153,6 @@
"output": "Sortie",
"overwrite": "Écraser",
"partner": "Partenaire",
"pause": "Pause",
"play": "Lecture",
"playPause": "Lecture/Pause",
"playRecording": "Lire l'enregistrement",
"playbackSpeed": "Vitesse de lecture",
@@ -1217,7 +1160,6 @@
"preloadError": "Une ressource requise na pas pu être chargée. Veuillez recharger la page.",
"preloadErrorTitle": "Erreur de chargement",
"preview": "APERÇU",
"previous": "Précédent",
"previousImage": "Image précédente",
"profile": "Profil",
"progressCountOf": "sur",
@@ -1292,8 +1234,6 @@
"showReport": "Afficher le rapport",
"showRightPanel": "Afficher le panneau de droite",
"singleSelectDropdown": "Menu déroulant à sélection unique",
"skipToEnd": "Aller à la fin",
"skipToStart": "Aller au début",
"sort": "Trier",
"source": "Source",
"startRecording": "Commencer lenregistrement",
@@ -1325,7 +1265,6 @@
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"videoPreview": "Aperçu de la vidéo - Utilisez les flèches pour naviguer entre les vidéos",
"viewGrid": "Vue grille",
"viewImageOfTotal": "Voir l'image {index} sur {total}",
"viewVideoOfTotal": "Voir la vidéo {index} sur {total}",
"volume": "Volume",
@@ -1893,7 +1832,6 @@
"mirrorVertical": "Miroir vertical",
"negative": "Négatif",
"opacity": "Opacité",
"openMaskEditor": "Ouvrir dans l'éditeur de masque",
"paintBucketSettings": "Paramètres du pot de peinture",
"paintLayer": "Calque de peinture",
"redo": "Rétablir",
@@ -2208,7 +2146,6 @@
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"PixVerse": "PixVerse",
"Quiver": "Quiver",
"Recraft": "Recraft",
"Reve": "Reve",
"Rodin": "Rodin",
@@ -2484,8 +2421,6 @@
"favoritesNoneDesc": "Les entrées que vous ajoutez en favori apparaîtront ici",
"favoritesNoneHint": "Dans longlet Paramètres, cliquez sur {moreIcon} à côté de nimporte quelle entrée pour lajouter ici",
"favoritesNoneTooltip": "Étoilez les widgets pour y accéder rapidement sans sélectionner de nœud",
"findOnGithubTooltip": "Rechercher des problèmes similaires sur GitHub",
"getHelpTooltip": "Signalez cette erreur et nous vous aiderons à la résoudre",
"globalSettings": {
"canvas": "CANEVA",
"connectionLinks": "LIENS DE CONNEXION",
@@ -3138,6 +3073,7 @@
"title": "Votre abonnement a été annulé"
},
"changeTo": "Changer pour {plan}",
"chooseBestPlanWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo Comfy Cloud",
"contactOwnerToSubscribe": "Contactez le propriétaire de lespace de travail pour vous abonner",
@@ -3167,7 +3103,6 @@
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Des questions ou besoin d'une offre entreprise ?",
"inviteUpTo": "Invitez jusquà",
"invoiceHistory": "Historique des factures",
"learnMore": "En savoir plus",
"managePayment": "Gérer le paiement",
@@ -3193,16 +3128,13 @@
"monthlyCreditsPerMemberLabel": "Crédits mensuels / membre",
"monthlyCreditsRollover": "Ces crédits seront reportés au mois suivant",
"mostPopular": "Le plus populaire",
"needTeamWorkspace": "Besoin dun espace de travail déquipe ?",
"nextBillingCycle": "prochain cycle de facturation",
"nextMonthInvoice": "Facture du mois prochain",
"partnerNodesBalance": "Solde de crédits \"Nœuds Partenaires\"",
"partnerNodesCredits": "Crédits Nœuds Partenaires",
"partnerNodesDescription": "Pour exécuter des modèles commerciaux/propriétaires",
"perMonth": "USD / mois",
"personalWorkspace": "Espace de travail personnel",
"plansAndPricing": "Forfaits & tarifs",
"plansForWorkspace": "Formules pour {workspace}",
"prepaidCreditsInfo": "Crédits achetés séparément qui n'expirent pas",
"prepaidDescription": "Crédits prépayés",
"preview": {
@@ -3238,7 +3170,6 @@
"resubscribe": "Se réabonner",
"resubscribeSuccess": "Abonnement réactivé avec succès",
"resubscribeTo": "Se réabonner à {plan}",
"soloUseOnly": "Usage solo uniquement",
"subscribeForMore": "Mettre à niveau",
"subscribeNow": "S'abonner maintenant",
"subscribeTo": "S'abonner à {plan}",
@@ -3246,7 +3177,6 @@
"subscribeToRun": "S'abonner",
"subscribeToRunFull": "S'abonner pour exécuter",
"subscriptionRequiredMessage": "Un abonnement est requis pour que les membres puissent exécuter des workflows sur le Cloud",
"teamWorkspace": "Espace de travail déquipe",
"tierNameYearly": "{name} Annuel",
"tiers": {
"creator": {
@@ -3298,18 +3228,6 @@
"duplicateTab": "Dupliquer l'onglet",
"removeFromBookmarks": "Retirer des Favoris"
},
"teamWorkspacesDialog": {
"confirmCallbackFailed": "Espace créé mais configuration incomplète",
"createWorkspace": "Créer un espace de travail",
"namePlaceholder": "ex. : Équipe Marketing",
"nameValidationError": "Le nom doit comporter entre 1 et 50 caractères, incluant lettres, chiffres, espaces ou ponctuation courante.",
"newWorkspace": "Nouvel espace de travail",
"subtitle": "Basculez vers un espace existant ou créez-en un nouveau",
"subtitleNoWorkspaces": "Créez un nouvel espace de travail déquipe pour partager des crédits",
"switch": "Basculer",
"title": "Espaces de travail déquipe",
"yourTeamWorkspaces": "Vos espaces de travail déquipe"
},
"templateWidgets": {
"sort": {
"searchPlaceholder": "Rechercher..."
@@ -3696,7 +3614,6 @@
},
"workspaceSwitcher": {
"createWorkspace": "Créer un nouvel espace de travail",
"failedToSwitch": "Échec du changement despace de travail",
"maxWorkspacesReached": "Vous ne pouvez posséder que 10 espaces de travail. Supprimez-en un pour en créer un nouveau.",
"personal": "Personnel",
"roleMember": "Membre",

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