mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-22 15:54:48 +00:00
Compare commits
30 Commits
fix/node-s
...
fix/folder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d540c6c0 | ||
|
|
611a1e2ece | ||
|
|
9c313c66a3 | ||
|
|
8099cce232 | ||
|
|
27d4a34435 | ||
|
|
e1e560403e | ||
|
|
aff0ebad50 | ||
|
|
44dc208339 | ||
|
|
388c21a88d | ||
|
|
b28f46d237 | ||
|
|
2900e5e52e | ||
|
|
07e64a7f44 | ||
|
|
34e21f3267 | ||
|
|
1349fffbce | ||
|
|
cde872fcf7 | ||
|
|
596df0f0c6 | ||
|
|
d3c0e331eb | ||
|
|
b47414a52f | ||
|
|
631d484901 | ||
|
|
e83e396c09 | ||
|
|
821c1e74ff | ||
|
|
d06cc0819a | ||
|
|
f5f5a77435 | ||
|
|
efe78b799f | ||
|
|
e70484d596 | ||
|
|
3dba245dd3 | ||
|
|
2ca0c30cf7 | ||
|
|
c8ba5f7300 | ||
|
|
39cc8ab97a | ||
|
|
2ee0a1337c |
2
.github/workflows/ci-lint-format.yaml
vendored
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal file
205
browser_tests/assets/missing/deprecated_nodes_complex.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "T2IAdapterLoader",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONTROL_NET",
|
||||
"type": "CONTROL_NET",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "T2IAdapterLoader"
|
||||
},
|
||||
"widgets_values": ["t2iadapter_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 300],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ResizeImagesByLongerEdge",
|
||||
"pos": [500, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ResizeImagesByLongerEdge"
|
||||
},
|
||||
"widgets_values": [1024]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageScaleBy",
|
||||
"pos": [500, 280],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageScaleBy"
|
||||
},
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ImageBatch",
|
||||
"pos": [900, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageBatch"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 300],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1250, 100],
|
||||
"size": [300, 250],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 4, 0, "IMAGE"],
|
||||
[2, 4, 0, 5, 0, "IMAGE"],
|
||||
[3, 4, 0, 6, 0, "IMAGE"],
|
||||
[4, 5, 0, 7, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal file
186
browser_tests/assets/missing/deprecated_nodes_simple.json
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Load3DAnimation",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MESH",
|
||||
"type": "MESH",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Load3DAnimation"
|
||||
},
|
||||
"widgets_values": ["model.glb"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Preview3DAnimation",
|
||||
"pos": [450, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mesh",
|
||||
"type": "MESH",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3DAnimation"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ConditioningAverage ",
|
||||
"pos": [100, 300],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "conditioning_to",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "conditioning_from",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ConditioningAverage "
|
||||
},
|
||||
"widgets_values": [1]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SDV_img2vid_Conditioning",
|
||||
"pos": [450, 300],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip_vision",
|
||||
"type": "CLIP_VISION",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "init_image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"links": [2],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SDV_img2vid_Conditioning"
|
||||
},
|
||||
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [800, 300],
|
||||
"size": [300, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 5, 1, "CONDITIONING"],
|
||||
[2, 4, 2, 5, 3, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"id": "save-image-and-webm-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 100],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1, 2]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SaveImage",
|
||||
"pos": [450, 100],
|
||||
"size": [210, 270],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "SaveWEBM",
|
||||
"pos": [450, 450],
|
||||
"size": [210, 368],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI", "vp9", 6, 32]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 10, 0, 11, 0, "IMAGE"],
|
||||
[2, 10, 0, 12, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.17.0",
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
|
||||
})
|
||||
|
||||
test('Does not add duplicate filter with same type and value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
test('Can remove filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
42
browser_tests/tests/saveImageAndWebp.spec.ts
Normal file
42
browser_tests/tests/saveImageAndWebp.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Save Image and WEBM preview',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Can preview both SaveImage and SaveWEBM outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// Wait for SaveWEBM to render a video inside .video-preview
|
||||
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'save-image-and-webm-preview.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,55 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
test('opens mask editor from image preview button', async ({ comfyPage }) => {
|
||||
const imagePreview = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Save Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
|
||||
})
|
||||
})
|
||||
25
global.d.ts
vendored
25
global.d.ts
vendored
@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
client_id: string | number | undefined
|
||||
session_id: string | number | undefined
|
||||
session_number: string | number | undefined
|
||||
}
|
||||
|
||||
interface GtagFunction {
|
||||
<TField extends GtagGetFieldName>(
|
||||
command: 'get',
|
||||
targetId: string,
|
||||
fieldName: TField,
|
||||
callback: (value: GtagGetFieldValueMap[TField]) => void
|
||||
): void
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -36,12 +55,8 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.5",
|
||||
"version": "1.40.7",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -215,6 +215,17 @@ describe('TopMenuSection', () => {
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the active jobs indicator when no jobs are active', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
@@ -60,7 +67,7 @@
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
@@ -68,6 +75,12 @@
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
@@ -139,6 +152,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
@@ -161,6 +175,7 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -245,6 +260,8 @@ const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
@@ -3,49 +3,26 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
|
||||
175
src/components/common/ScrubableNumberInput.vue
Normal file
175
src/components/common/ScrubableNumberInput.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
:value="displayValue ?? modelValue"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
displayValue,
|
||||
parseValue
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
hideButtons?: boolean
|
||||
displayValue?: string
|
||||
parseValue?: (raw: string) => number | undefined
|
||||
inputAttrs?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
if (textEdit.value) textEdit.value = false
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const raw = target.value.trim()
|
||||
const parsed = parseValue
|
||||
? parseValue(raw)
|
||||
: raw === ''
|
||||
? undefined
|
||||
: Number(raw)
|
||||
if (parsed != null && !isNaN(parsed)) {
|
||||
modelValue.value = clamp(parsed)
|
||||
} else {
|
||||
target.value = displayValue ?? String(modelValue.value)
|
||||
}
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
@@ -14,32 +14,210 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<!-- QUICK FIX AVAILABLE Section -->
|
||||
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
|
||||
<!-- Section header with Replace button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-primary">
|
||||
{{ $t('nodeReplacement.quickFixAvailable') }}
|
||||
</span>
|
||||
<div class="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
|
||||
variant="primary"
|
||||
size="md"
|
||||
:disabled="selectedTypes.size === 0"
|
||||
@click="handleReplaceSelected"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
|
||||
{{
|
||||
$t('nodeReplacement.replaceSelected', {
|
||||
count: selectedTypes.size
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable nodes list -->
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
:key="i"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
||||
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
<!-- Select All row (sticky header) -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
|
||||
pendingNodes.length > 0
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
|
||||
"
|
||||
@click="toggleSelectAll"
|
||||
@keydown.enter.prevent="toggleSelectAll"
|
||||
@keydown.space.prevent="toggleSelectAll"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
isAllSelected || isSomeSelected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isAllSelected"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isSomeSelected"
|
||||
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium uppercase text-muted-foreground">
|
||||
{{ $t('nodeReplacement.compatibleAlternatives') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable node items -->
|
||||
<div
|
||||
v-for="node in replaceableNodes"
|
||||
:key="node.label"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2',
|
||||
replacedTypes.has(node.label)
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'true'
|
||||
: 'false'
|
||||
"
|
||||
@click="toggleNode(node.label)"
|
||||
@keydown.enter.prevent="toggleNode(node.label)"
|
||||
@keydown.space.prevent="toggleNode(node.label)"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="replacedTypes.has(node.label)"
|
||||
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaced') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.replacementInstruction')
|
||||
: $t('missingNodes.oss.replacementInstruction')
|
||||
}}
|
||||
<!-- MANUAL INSTALLATION REQUIRED Section -->
|
||||
<div
|
||||
v-if="nonReplaceableNodes.length > 0"
|
||||
class="flex max-h-[200px] flex-col gap-2"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-error">
|
||||
{{ $t('nodeReplacement.installationRequired') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--info] text-xs text-error" />
|
||||
</div>
|
||||
|
||||
<!-- Non-replaceable nodes list -->
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="node in nonReplaceableNodes"
|
||||
:key="node.label"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
|
||||
>
|
||||
{{ $t('nodeReplacement.notReplaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||
{{ node.hint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="node.action"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="node.action.callback"
|
||||
>
|
||||
{{ node.action.text }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction box -->
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p class="m-0 text-xs leading-5 text-neutral-foreground">
|
||||
<i18n-t keypath="nodeReplacement.instructionMessage">
|
||||
<template #red>
|
||||
<span class="text-error">{{
|
||||
$t('nodeReplacement.redHighlight')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,23 +225,39 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const props = defineProps<{
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
interface ProcessedNode {
|
||||
label: string
|
||||
hint?: string
|
||||
action?: { text: string; callback: () => void }
|
||||
isReplaceable: boolean
|
||||
replacement?: NodeReplacement
|
||||
}
|
||||
|
||||
const replacedTypes = ref<Set<string>>(new Set())
|
||||
|
||||
const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
const seenTypes = new Set<string>()
|
||||
return missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
@@ -75,10 +269,81 @@ const uniqueNodes = computed(() => {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action
|
||||
action: node.action,
|
||||
isReplaceable: node.isReplaceable ?? false,
|
||||
replacement: node.replacement
|
||||
}
|
||||
}
|
||||
return { label: node }
|
||||
return { label: node, isReplaceable: false }
|
||||
})
|
||||
})
|
||||
|
||||
const replaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => n.isReplaceable)
|
||||
)
|
||||
|
||||
const pendingNodes = computed(() =>
|
||||
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const nonReplaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => !n.isReplaceable)
|
||||
)
|
||||
|
||||
// Selection state - all pending nodes selected by default
|
||||
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
|
||||
|
||||
const isAllSelected = computed(
|
||||
() =>
|
||||
pendingNodes.value.length > 0 &&
|
||||
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const isSomeSelected = computed(
|
||||
() => selectedTypes.value.size > 0 && !isAllSelected.value
|
||||
)
|
||||
|
||||
function toggleNode(label: string) {
|
||||
if (replacedTypes.value.has(label)) return
|
||||
const next = new Set(selectedTypes.value)
|
||||
if (next.has(label)) {
|
||||
next.delete(label)
|
||||
} else {
|
||||
next.add(label)
|
||||
}
|
||||
selectedTypes.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedTypes.value = new Set()
|
||||
} else {
|
||||
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceSelected() {
|
||||
const selected = missingNodeTypes.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
return selectedTypes.value.has(type)
|
||||
})
|
||||
|
||||
const result = replaceNodesInPlace(selected)
|
||||
const nextReplaced = new Set(replacedTypes.value)
|
||||
const nextSelected = new Set(selectedTypes.value)
|
||||
for (const type of result) {
|
||||
nextReplaced.add(type)
|
||||
nextSelected.delete(type)
|
||||
}
|
||||
replacedTypes.value = nextReplaced
|
||||
selectedTypes.value = nextSelected
|
||||
|
||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||
const allReplaced = replaceableNodes.value.every((n) =>
|
||||
nextReplaced.has(n.label)
|
||||
)
|
||||
if (allReplaced && nonReplaceableNodes.value.length === 0) {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,8 +30,18 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
|
||||
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">
|
||||
{{ $t('nodeReplacement.skipForNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||
<div
|
||||
v-else-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@@ -48,9 +58,9 @@
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<!-- OSS mode: Manager buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
<Button variant="textonly" @click="handleOpenManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
@@ -82,12 +92,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -109,6 +124,12 @@ function openShowMissingNodesSetting() {
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
function handleOpenManager() {
|
||||
managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
@@ -128,15 +149,29 @@ const showInstallAllButton = computed(() => {
|
||||
return managerState.shouldShowInstallButton.value
|
||||
})
|
||||
|
||||
const openManager = async () => {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
const hasNonReplaceableNodes = computed(
|
||||
() =>
|
||||
missingNodeTypes?.some(
|
||||
(n) =>
|
||||
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
|
||||
) ?? false
|
||||
)
|
||||
|
||||
// Computed to check if all missing nodes have been installed
|
||||
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
|
||||
const hadMissingPacks = ref(false)
|
||||
|
||||
watch(
|
||||
missingNodePacks,
|
||||
(packs) => {
|
||||
if (packs && packs.length > 0) hadMissingPacks.value = true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Only consider "all installed" when packs transitioned from non-empty to empty
|
||||
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
if (!hadMissingPacks.value) return false
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@pointerdown.capture="forwardPanEvent"
|
||||
@pointerup.capture="forwardPanEvent"
|
||||
@pointermove.capture="forwardPanEvent"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<LGraphNode
|
||||
@@ -114,6 +117,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -160,6 +164,7 @@ import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
@@ -540,4 +545,13 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
|
||||
!isMiddlePointerInput(e)
|
||||
)
|
||||
return
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,13 +26,13 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
|
||||
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none brightness-50"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
@@ -36,14 +36,12 @@
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
|
||||
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
>
|
||||
<div class="pointer-events-none size-full" :style="cropImageStyle" />
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
@@ -131,7 +129,6 @@ const {
|
||||
isLockEnabled,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Completed: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrl: thumbnail('4dabf7')
|
||||
thumbnailUrls: [thumbnail('4dabf7')]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const Gallery: Story = {
|
||||
const completed = args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrl: thumbnail('ff6b6b')
|
||||
thumbnailUrls: [thumbnail('ff6b6b')]
|
||||
})
|
||||
const completedMultiple = args({
|
||||
type: 'completed',
|
||||
|
||||
@@ -71,11 +71,6 @@ const thumbnailUrls = computed(() => {
|
||||
if (notification.type !== 'completed') {
|
||||
return []
|
||||
}
|
||||
if (typeof notification.thumbnailUrl === 'string') {
|
||||
return notification.thumbnailUrl.length > 0
|
||||
? [notification.thumbnailUrl]
|
||||
: []
|
||||
}
|
||||
return notification.thumbnailUrls?.slice(0, 2) ?? []
|
||||
})
|
||||
|
||||
|
||||
@@ -4,46 +4,17 @@
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@clear-queued="$emit('clearQueued')"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<Button
|
||||
class="grow gap-1 justify-center"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
|
||||
</Button>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
>
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
class="ml-2"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobFiltersBar
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="$emit('showAssets')"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
@@ -71,9 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
@@ -112,8 +81,6 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const i18n = createI18n({
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history'
|
||||
}
|
||||
@@ -54,6 +56,7 @@ const mountHeader = (props = {}) =>
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
queuedCount: 3,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -80,6 +83,25 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows queued summary and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
|
||||
expect(wrapper.text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('queued')
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
|
||||
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
|
||||
>
|
||||
<div class="px-2 text-[14px] font-normal text-text-primary">
|
||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
@@ -17,6 +17,25 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||
>
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!isCloud" class="flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
@@ -78,10 +97,12 @@ defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
99
src/components/queue/QueueProgressOverlay.test.ts
Normal file
99
src/components/queue/QueueProgressOverlay.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const QueueOverlayExpandedStub = defineComponent({
|
||||
name: 'QueueOverlayExpanded',
|
||||
props: {
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl({
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
runningTasks: TaskItemImpl[],
|
||||
pendingTasks: TaskItemImpl[]
|
||||
) => {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
queueStore.runningTasks = runningTasks
|
||||
queueStore.pendingTasks = pendingTasks
|
||||
|
||||
return mount(QueueProgressOverlay, {
|
||||
props: {
|
||||
expanded: true
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
ResultGallery: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('QueueProgressOverlay', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
it('shows expanded header with running and queued labels', () => {
|
||||
const wrapper = mountComponent(
|
||||
[
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
],
|
||||
[createTask('pending-1', 'pending')]
|
||||
)
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'2 running, 1 queued'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only running label when queued count is zero', () => {
|
||||
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'1 running'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows job queue title when there are no active jobs', () => {
|
||||
const wrapper = mountComponent([], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'Job Queue'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -92,7 +92,7 @@ const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -126,7 +126,6 @@ const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isExecuting = computed(() => !executionStore.isIdle)
|
||||
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
|
||||
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
||||
|
||||
const overlayState = computed<OverlayState>(() => {
|
||||
if (isExpanded.value) return 'expanded'
|
||||
@@ -156,11 +155,34 @@ const bottomRowClass = computed(
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
: t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
const runningJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
|
||||
count: n(runningCount.value)
|
||||
})
|
||||
)
|
||||
const queuedJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
|
||||
count: n(queuedCount.value)
|
||||
})
|
||||
)
|
||||
const headerTitle = computed(() => {
|
||||
if (!hasActiveJob.value) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
}
|
||||
|
||||
if (queuedCount.value === 0) {
|
||||
return runningJobsLabel.value
|
||||
}
|
||||
|
||||
if (runningCount.value === 0) {
|
||||
return queuedJobsLabel.value
|
||||
}
|
||||
|
||||
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
|
||||
running: runningJobsLabel.value,
|
||||
queued: queuedJobsLabel.value
|
||||
})
|
||||
})
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
|
||||
79
src/components/queue/job/JobFiltersBar.test.ts
Normal file
79
src/components/queue/job/JobFiltersBar.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots, expose }) {
|
||||
expose({
|
||||
hide: () => undefined,
|
||||
toggle: (_event: Event) => undefined
|
||||
})
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
all: 'All',
|
||||
completed: 'Completed'
|
||||
},
|
||||
queue: {
|
||||
jobList: {
|
||||
sortMostRecent: 'Most recent',
|
||||
sortTotalGenerationTime: 'Total generation time'
|
||||
}
|
||||
},
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
filterJobs: 'Filter jobs',
|
||||
filterBy: 'Filter by',
|
||||
sortJobs: 'Sort jobs',
|
||||
sortBy: 'Sort by',
|
||||
showAssets: 'Show assets',
|
||||
showAssetsPanel: 'Show assets panel',
|
||||
filterAllWorkflows: 'All workflows',
|
||||
filterCurrentWorkflow: 'Current workflow'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobFiltersBar', () => {
|
||||
it('emits showAssets when the assets icon button is clicked', async () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => undefined }
|
||||
}
|
||||
})
|
||||
|
||||
const showAssetsButton = wrapper.get(
|
||||
'button[aria-label="Show assets panel"]'
|
||||
)
|
||||
await showAssetsButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('showAssets')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -127,6 +127,15 @@
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
v-tooltip.top="showAssetsTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -150,6 +159,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
@@ -165,6 +175,9 @@ const filterTooltipConfig = computed(() =>
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
const showAssetsTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
|
||||
)
|
||||
|
||||
// This can be removed when cloud implements /jobs and we switch to it.
|
||||
const showWorkflowFilter = !isCloud
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useFlatAndCategorizeSelectedItems
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -40,6 +41,8 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -102,7 +105,10 @@ const selectedNodeErrors = computed(() =>
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (selectedNodeErrors.value.length) {
|
||||
if (
|
||||
selectedNodeErrors.value.length &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
@@ -110,6 +116,18 @@ const tabs = computed<RightSidePanelTabList>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
hasAnyError.value &&
|
||||
!hasSelection.value &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
value: 'errors',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
flattedItems.value.length > 1
|
||||
@@ -298,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<template v-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
|
||||
162
src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
Normal file
162
src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
details: 'Input: text\nExpected: STRING'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
details: ''
|
||||
},
|
||||
{
|
||||
message: 'Value "NaN" is not a valid number for "strength".',
|
||||
details: 'Expected: FLOAT [0.0 .. 1.0]'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
details: [
|
||||
'Traceback (most recent call last):',
|
||||
' File "ksampler.py", line 142, in sample',
|
||||
' samples = model.apply(latent)',
|
||||
'RuntimeError: CUDA out of memory.'
|
||||
].join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
110
src/components/rightSidePanel/errors/ErrorNodeCard.vue
Normal file
110
src/components/rightSidePanel/errors/ErrorNodeCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header (Node ID & Actions) -->
|
||||
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle"
|
||||
class="flex-1 text-sm text-muted-foreground truncate font-medium"
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div class="divide-y divide-interface-stroke/20 space-y-4">
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details -->
|
||||
<div
|
||||
v-if="error.details"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
|
||||
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
|
||||
>
|
||||
{{ error.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
{{ t('g.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
[error.message, error.details].filter(Boolean).join('\n\n')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
218
src/components/rightSidePanel/errors/TabErrors.test.ts
Normal file
218
src/components/rightSidePanel/errors/TabErrors.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
promptErrors: {
|
||||
prompt_no_outputs: {
|
||||
desc: 'Prompt has no outputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mountComponent(initialState = {}) {
|
||||
return mount(TabErrors, {
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders "no errors" state when store is empty', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('No errors')
|
||||
})
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Group title should be the raw message from store
|
||||
expect(wrapper.text()).toContain('Server Error: No outputs')
|
||||
// Item message should be localized desc
|
||||
expect(wrapper.text()).toContain('Prompt has no outputs')
|
||||
// Details should not be rendered for prompt errors
|
||||
expect(wrapper.text()).not.toContain('Error details')
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('#6')
|
||||
expect(wrapper.text()).toContain('CLIP Text Encode')
|
||||
expect(wrapper.text()).toContain('Required input is missing')
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'Out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Line 1', 'Line 2'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
expect(wrapper.text()).toContain('Line 1')
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Missing text input' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'KSampler',
|
||||
errors: [{ message: 'Out of memory' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
|
||||
const searchInput = wrapper.find('input')
|
||||
await searchInput.setValue('Missing text input')
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Find the copy button (rendered inside ErrorNodeCard)
|
||||
const copyButtons = wrapper.findAll('button')
|
||||
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
})
|
||||
167
src/components/rightSidePanel/errors/TabErrors.vue
Normal file
167
src/components/rightSidePanel/errors/TabErrors.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-w-0">
|
||||
<!-- Search bar -->
|
||||
<div
|
||||
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||
/>
|
||||
<span class="text-destructive-background-hover truncate">
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted-foreground leading-tight break-words"
|
||||
>
|
||||
<template #github>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="openGitHubIssues"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpGithub') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #support>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="contactSupport"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpSupport') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
21
src/components/rightSidePanel/errors/types.ts
Normal file
21
src/components/rightSidePanel/errors/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ErrorItem {
|
||||
message: string
|
||||
details?: string
|
||||
isRuntimeError?: boolean
|
||||
}
|
||||
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
export interface ErrorGroup {
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
}
|
||||
236
src/components/rightSidePanel/errors/useErrorGroups.ts
Normal file
236
src/components/rightSidePanel/errors/useErrorGroups.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
|
||||
interface ErrorSearchItem {
|
||||
groupIndex: number
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
function resolveNodeInfo(nodeId: string): {
|
||||
title: string
|
||||
graphNodeId: string | undefined
|
||||
} {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
return {
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
title: string,
|
||||
priority = 1
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(title)
|
||||
if (!entry) {
|
||||
entry = { priority, cards: new Map() }
|
||||
groupsMap.set(title, entry)
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (!executionStore.lastPromptError) return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
cards.set('__prompt__', {
|
||||
id: '__prompt__',
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `node-${nodeId}`,
|
||||
title: nodeError.class_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) continue
|
||||
card.errors.push(
|
||||
...nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const nodeId = String(e.node_id)
|
||||
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
|
||||
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `exec-${nodeId}`,
|
||||
title: e.node_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push({
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
})
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([title, groupData]) => ({
|
||||
title,
|
||||
cards: Array.from(groupData.cards.values()),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}
|
||||
|
||||
function buildErrorGroups(
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
): ErrorGroup[] {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap, executionStore, t)
|
||||
processNodeErrors(groupsMap, executionStore)
|
||||
processExecutionError(groupsMap, executionStore)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]!
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]!
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
}
|
||||
|
||||
const fuse = new Fuse(searchableList, fuseOptions)
|
||||
const results = fuse.search(query)
|
||||
|
||||
const matchedCardKeys = new Set(
|
||||
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
|
||||
)
|
||||
|
||||
return groups
|
||||
.map((group, gi) => ({
|
||||
...group,
|
||||
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
|
||||
}))
|
||||
.filter((group) => group.cards.length > 0)
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
)
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -60,6 +64,8 @@ watchEffect(() => (widgets.value = widgetsProp))
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -104,6 +110,11 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
@@ -122,6 +133,13 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToErrorTab() {
|
||||
if (!targetNode.value) return
|
||||
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
|
||||
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
@@ -162,9 +180,20 @@ defineExpose({
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<i
|
||||
v-if="nodeHasError"
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'truncate',
|
||||
nodeHasError && 'text-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
@@ -177,6 +206,15 @@ defineExpose({
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="textonly"
|
||||
|
||||
173
src/components/searchbox/NodeSearchBoxPopover.test.ts
Normal file
173
src/components/searchbox/NodeSearchBoxPopover.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
const mockStoreRefs = vi.hoisted(() => ({
|
||||
visible: { value: false },
|
||||
newSearchBoxEnabled: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual('pinia')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
storeToRefs: () => mockStoreRefs
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/workspace/searchBoxStore', () => ({
|
||||
useSearchBoxStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
getCanvasCenter: vi.fn(() => [0, 0]),
|
||||
addNodeOnGraph: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: null,
|
||||
getCanvas: vi.fn(() => ({
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
renderLinks: []
|
||||
}
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeSearchService: {
|
||||
nodeFilters: [],
|
||||
inputTypeFilter: {},
|
||||
outputTypeFilter: {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
template: '<div class="node-search-box" />'
|
||||
})
|
||||
|
||||
function createFilter(
|
||||
id: string,
|
||||
value: string
|
||||
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
|
||||
return {
|
||||
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
describe('NodeSearchBoxPopover', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockStoreRefs.visible.value = false
|
||||
})
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(NodeSearchBoxPopover, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
props: ['visible', 'modal', 'dismissableMask', 'pt']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('addFilter duplicate prevention', () => {
|
||||
it('should add a filter when no duplicates exist', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const filters = searchBox.props('filters') as FuseFilterWithValue<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>[]
|
||||
expect(filters).toHaveLength(1)
|
||||
expect(filters[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'outputType' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a duplicate filter with same id and value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should allow filters with same id but different values', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should allow filters with different ids but same value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -71,7 +71,12 @@ function getNewNodeLocation(): Point {
|
||||
}
|
||||
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
|
||||
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
nodeFilters.value.push(filter)
|
||||
const isDuplicate = nodeFilters.value.some(
|
||||
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
|
||||
)
|
||||
if (!isDuplicate) {
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
}
|
||||
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
nodeFilters.value = nodeFilters.value.filter(
|
||||
|
||||
@@ -79,8 +79,21 @@
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="showLoadingState">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
<div
|
||||
v-if="showLoadingState"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
|
||||
>
|
||||
<div
|
||||
v-for="n in skeletonCount"
|
||||
:key="`skeleton-${n}`"
|
||||
class="flex flex-col gap-2 p-2"
|
||||
>
|
||||
<Skeleton class="aspect-square w-full rounded-lg" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<Skeleton class="h-4 w-3/4" />
|
||||
<Skeleton class="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showEmptyState">
|
||||
<NoResultsPlaceholder
|
||||
@@ -206,6 +219,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
@@ -213,7 +227,6 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -225,6 +238,7 @@ const Load3dViewerContent = () =>
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
@@ -237,6 +251,7 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
@@ -260,6 +275,7 @@ const settingStore = useSettingStore()
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const expectedFolderCount = ref(0)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = useStorage<'list' | 'grid'>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
@@ -376,7 +392,24 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const currentGalleryAssetId = ref<string | null>(null)
|
||||
|
||||
const folderAssets = ref<AssetItem[]>([])
|
||||
const DEFAULT_SKELETON_COUNT = 6
|
||||
const skeletonCount = computed(() =>
|
||||
expectedFolderCount.value > 0
|
||||
? expectedFolderCount.value
|
||||
: DEFAULT_SKELETON_COUNT
|
||||
)
|
||||
|
||||
const {
|
||||
state: folderAssets,
|
||||
isLoading: folderLoading,
|
||||
error: folderError,
|
||||
execute: loadFolderAssets
|
||||
} = useAsyncState(
|
||||
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
|
||||
resolveOutputAssetItems(metadata, options),
|
||||
[] as AssetItem[],
|
||||
{ immediate: false, resetOnExecute: true }
|
||||
)
|
||||
|
||||
// Base assets before search filtering
|
||||
const baseAssets = computed(() => {
|
||||
@@ -414,9 +447,13 @@ const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
const isFolderLoading = computed(
|
||||
() => isInFolderView.value && folderLoading.value
|
||||
)
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
(loading.value || isFolderLoading.value) &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
@@ -424,6 +461,7 @@ const showLoadingState = computed(
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!isFolderLoading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
@@ -599,27 +637,25 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
expectedFolderCount.value = metadata.outputCount ?? 0
|
||||
|
||||
let folderItems: AssetItem[] = []
|
||||
try {
|
||||
folderItems = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
|
||||
|
||||
if (folderError.value) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('sideToolbar.folderView.errorSummary'),
|
||||
detail: t('sideToolbar.folderView.errorDetail'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve outputs for folder view:', error)
|
||||
exitFolderView()
|
||||
}
|
||||
|
||||
if (folderItems.length === 0) {
|
||||
console.warn('No outputs available for folder view')
|
||||
return
|
||||
}
|
||||
|
||||
folderAssets.value = folderItems
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
expectedFolderCount.value = 0
|
||||
folderAssets.value = []
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -80,7 +80,8 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
() =>
|
||||
import('../../platform/workspace/components/CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
|
||||
15
src/components/ui/skeleton/Skeleton.vue
Normal file
15
src/components/ui/skeleton/Skeleton.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('animate-pulse rounded-md bg-secondary-background', className)"
|
||||
/>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from './useWorkspaceBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
|
||||
56
src/composables/canvas/useFocusNode.ts
Normal file
56
src/composables/canvas/useFocusNode.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return
|
||||
|
||||
if (canvas.graph !== targetGraph) {
|
||||
canvas.subgraph = targetGraph.isRootGraph
|
||||
? undefined
|
||||
: (targetGraph as Subgraph)
|
||||
canvas.setGraph(targetGraph)
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Double RAF to wait for LiteGraph's internal canvas frame cycle
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
async function focusNode(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type QueueQueuedNotification = {
|
||||
type QueueCompletedNotification = {
|
||||
type: 'completed'
|
||||
count: number
|
||||
thumbnailUrl?: string
|
||||
thumbnailUrls?: string[]
|
||||
}
|
||||
|
||||
@@ -38,7 +37,7 @@ export type QueueNotificationBanner =
|
||||
| QueueFailedNotification
|
||||
|
||||
const sanitizeCount = (value: number | undefined) => {
|
||||
if (value === undefined || Number.isNaN(value) || value <= 0) {
|
||||
if (!(typeof value === 'number' && value > 0)) {
|
||||
return 1
|
||||
}
|
||||
return Math.floor(value)
|
||||
|
||||
@@ -20,7 +20,8 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled'
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +97,9 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -155,11 +155,18 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
const getInputImageUrl = (): string | null => {
|
||||
if (!node.value) return null
|
||||
|
||||
const inputNode = node.value.getInputNode(0)
|
||||
let sourceNode = node.value.getInputNode(0)
|
||||
if (!sourceNode) return null
|
||||
|
||||
if (!inputNode) return null
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const link = node.value.getInputLink(0)
|
||||
if (!link) return null
|
||||
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
|
||||
sourceNode = resolved?.outputNode ?? null
|
||||
if (!sourceNode) return null
|
||||
}
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
|
||||
|
||||
if (urls?.length) {
|
||||
return urls[0]
|
||||
@@ -236,17 +243,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
const cropImageStyle = computed(() => {
|
||||
if (!imageUrl.value) return {}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${imageUrl.value})`,
|
||||
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
|
||||
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}
|
||||
})
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
@@ -562,7 +558,10 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value = app.rootGraph?.getNodeById(nodeId) || null
|
||||
node.value =
|
||||
app.canvas?.graph?.getNodeById(nodeId) ||
|
||||
app.rootGraph?.getNodeById(nodeId) ||
|
||||
null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
@@ -595,7 +594,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
isLockEnabled,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
|
||||
@@ -201,11 +201,10 @@ describe('pasteImageNodes', () => {
|
||||
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const fileList = createDataTransfer([file1, file2]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
[file1, file2]
|
||||
)
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
@@ -217,11 +216,9 @@ describe('pasteImageNodes', () => {
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const fileList = createDataTransfer([]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
fileList
|
||||
[]
|
||||
)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function pasteImageNode(
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: FileList
|
||||
fileList: File[]
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -5,6 +7,38 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.innerHTML = options.title
|
||||
element.textContent = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
const label = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.innerHTML = innerHtml
|
||||
element.textContent = label
|
||||
} else {
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "المُخرجات",
|
||||
"type": "النوع"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "بدائل متوافقة",
|
||||
"installMissingNodes": "تثبيت العقد المفقودة",
|
||||
"installationRequired": "التثبيت مطلوب",
|
||||
"instructionMessage": "يجب عليك تثبيت هذه العقد أو استبدالها ببدائل مثبتة لتشغيل سير العمل. العقد المفقودة مميزة باللون {red} على اللوحة. بعض العقد لا يمكن استبدالها ويجب تثبيتها عبر مدير العقد.",
|
||||
"notReplaceable": "التثبيت مطلوب",
|
||||
"openNodeManager": "فتح مدير العقد",
|
||||
"quickFixAvailable": "إصلاح سريع متاح",
|
||||
"redHighlight": "أحمر",
|
||||
"replaceFailed": "فشل في استبدال العقد",
|
||||
"replaceSelected": "استبدال المحدد ({count})",
|
||||
"replaceWarning": "سيؤدي هذا إلى تعديل سير العمل بشكل دائم. احفظ نسخة أولاً إذا لم تكن متأكدًا.",
|
||||
"replaceable": "قابل للاستبدال",
|
||||
"replaced": "تم الاستبدال",
|
||||
"replacedAllNodes": "تم استبدال {count} نوع/أنواع من العقد",
|
||||
"replacedNode": "تم استبدال العقدة: {nodeType}",
|
||||
"selectAll": "تحديد الكل",
|
||||
"skipForNow": "تخطي الآن"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "أدخل الاسم",
|
||||
"saveAsTemplate": "حفظ كقالب"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "خيارات إضافية",
|
||||
"noActiveJobs": "لا توجد مهام نشطة",
|
||||
"preview": "معاينة",
|
||||
"queuedJobsLabel": "{count} في الانتظار",
|
||||
"queuedSuffix": "في الانتظار",
|
||||
"running": "قيد التشغيل",
|
||||
"runningJobsLabel": "{count} قيد التشغيل",
|
||||
"runningQueuedSummary": "{running} قيد التشغيل، {queued} في الانتظار",
|
||||
"showAssets": "عرض الأصول",
|
||||
"showAssetsPanel": "عرض لوحة الأصول",
|
||||
"sortBy": "ترتيب حسب",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "يطبق توجيه الانتباه المعياري على النماذج، مما يتيح استخدام المطالبات السلبية على النماذج المقطرة/schnell.",
|
||||
"display_name": "توجيه الانتباه المعياري",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج الذي سيتم تطبيق NAG عليه."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "معامل المزج",
|
||||
"tooltip": "معامل المزج للانتباه المعياري. القيمة 1.0 تعني استبدال كامل، 0.0 تعني عدم وجود تأثير."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "عامل مقياس التوجيه",
|
||||
"tooltip": "عامل مقياس التوجيه. القيم الأعلى تدفع أبعد عن المطالبة السلبية."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "النموذج المعدل مع تفعيل NAG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "تطبيع الصور",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "ينتج صورًا باستخدام نماذج Recraft V4 أو V4 Pro.",
|
||||
"display_name": "Recraft V4 تحويل النص إلى صورة",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم في التوليد."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "الحجم"
|
||||
},
|
||||
"n": {
|
||||
"name": "عدد الصور",
|
||||
"tooltip": "عدد الصور المراد إنشاؤها."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "المطالبة السلبية",
|
||||
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "عناصر تحكم Recraft",
|
||||
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "ينتج SVG باستخدام نماذج Recraft V4 أو V4 Pro.",
|
||||
"display_name": "Recraft V4 تحويل النص إلى متجه",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم في التوليد."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "الحجم"
|
||||
},
|
||||
"n": {
|
||||
"name": "عدد الصور",
|
||||
"tooltip": "عدد الصور المراد إنشاؤها."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "المطالبة السلبية",
|
||||
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "عناصر تحكم Recraft",
|
||||
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "ينشئ SVG بشكل متزامن من صورة إدخال.",
|
||||
"display_name": "إعادة صياغة تحويل الصورة إلى متجه",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "إنشاء فيديو من إطار بداية، إطار نهاية، ونص توجيهي.",
|
||||
"display_name": "توليد فيديو من إطار البداية/النهاية باستخدام Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "إطار النهاية"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "إطار البداية"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم لتوليد الفيديو."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "الصوت"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص التوجيهي",
|
||||
"tooltip": "وصف النص التوجيهي (بحد أقصى ٢٠٠٠ حرف)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "إنشاء فيديو من نص.",
|
||||
"display_name": "توليد فيديو من نص Vidu Q3",
|
||||
|
||||
@@ -743,6 +743,10 @@
|
||||
"filterText": "Text"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"folderView": {
|
||||
"errorSummary": "Failed to load outputs",
|
||||
"errorDetail": "Could not retrieve outputs for this job. Please try again."
|
||||
},
|
||||
"searchAssets": "Search Assets",
|
||||
"labels": {
|
||||
"queue": "Queue",
|
||||
@@ -814,6 +818,9 @@
|
||||
"activeJobs": "{count} active job | {count} active jobs",
|
||||
"activeJobsShort": "{count} active | {count} active",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"runningJobsLabel": "{count} running",
|
||||
"queuedJobsLabel": "{count} queued",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
"viewJobHistory": "View active jobs (right-click to clear queue)",
|
||||
@@ -1564,6 +1571,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"": "",
|
||||
"OpenAI": "OpenAI",
|
||||
"Sora": "Sora",
|
||||
"cond pair": "cond pair",
|
||||
@@ -1588,7 +1596,6 @@
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"": "",
|
||||
"camera": "camera",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
@@ -2814,9 +2821,10 @@
|
||||
"insertAllAssetsAsNodes": "Insert all assets as nodes",
|
||||
"openWorkflowAll": "Open all workflows",
|
||||
"exportWorkflowAll": "Export all workflows",
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
|
||||
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
|
||||
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
|
||||
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
|
||||
@@ -2900,6 +2908,25 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"quickFixAvailable": "Quick Fix Available",
|
||||
"installationRequired": "Installation Required",
|
||||
"compatibleAlternatives": "Compatible Alternatives",
|
||||
"replaceable": "Replaceable",
|
||||
"replaced": "Replaced",
|
||||
"notReplaceable": "Install Required",
|
||||
"selectAll": "Select All",
|
||||
"replaceSelected": "Replace Selected ({count})",
|
||||
"replacedNode": "Replaced node: {nodeType}",
|
||||
"replacedAllNodes": "Replaced {count} node type(s)",
|
||||
"replaceFailed": "Failed to replace nodes",
|
||||
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
|
||||
"redHighlight": "red",
|
||||
"openNodeManager": "Open Node Manager",
|
||||
"skipForNow": "Skip for Now",
|
||||
"installMissingNodes": "Install Missing Nodes",
|
||||
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
|
||||
},
|
||||
"rightSidePanel": {
|
||||
"togglePanel": "Toggle properties panel",
|
||||
"noSelection": "Select a node to see its properties and info.",
|
||||
@@ -2955,6 +2982,21 @@
|
||||
"fallbackGroupTitle": "Group",
|
||||
"fallbackNodeTitle": "Node",
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
}
|
||||
},
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
"errorHelpSupport": "contact our support",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
},
|
||||
@@ -2978,6 +3020,20 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"exportToast": {
|
||||
"exportingAssets": "Exporting Assets",
|
||||
"preparingExport": "Preparing export...",
|
||||
"exportError": "Export failed",
|
||||
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
|
||||
"allExportsCompleted": "All exports completed",
|
||||
"noExportsInQueue": "No {filter} exports in queue",
|
||||
"exportStarted": "Preparing ZIP download...",
|
||||
"exportCompleted": "ZIP download ready",
|
||||
"exportFailedSingle": "Failed to create ZIP export",
|
||||
"downloadExport": "Download export",
|
||||
"downloadFailed": "Failed to download \"{name}\"",
|
||||
"retryDownload": "Retry download"
|
||||
},
|
||||
"workspace": {
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
|
||||
@@ -10399,6 +10399,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"display_name": "Normalized Attention Guidance",
|
||||
"description": "Applies Normalized Attention Guidance to models, enabling negative prompts on distilled/schnell models.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to apply NAG to."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "The guidance scale factor. Higher values push further from the negative prompt."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Blending factor for the normalized attention. 1.0 is full replacement, 0.0 is no effect."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "The patched model with NAG enabled."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normalize Images",
|
||||
"inputs": {
|
||||
@@ -11647,7 +11673,7 @@
|
||||
},
|
||||
"RecraftStyleV3InfiniteStyleLibrary": {
|
||||
"display_name": "Recraft Style - Infinite Style Library",
|
||||
"description": "Select style based on preexisting UUID from Recraft's Infinite Style Library.",
|
||||
"description": "Choose style based on preexisting UUID from Recraft's Infinite Style Library.",
|
||||
"inputs": {
|
||||
"style_id": {
|
||||
"name": "style_id",
|
||||
@@ -11773,6 +11799,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"display_name": "Recraft V4 Text to Image",
|
||||
"description": "Generates images using Recraft V4 or V4 Pro models.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "An optional text description of undesired elements on an image."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for generation."
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "The number of images to generate."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"display_name": "Recraft V4 Text to Vector",
|
||||
"description": "Generates SVG using Recraft V4 or V4 Pro models.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "An optional text description of undesired elements on an image."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for generation."
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "The number of images to generate."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"display_name": "Recraft Vectorize Image",
|
||||
"description": "Generates SVG synchronously from an input image.",
|
||||
@@ -15984,6 +16092,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"display_name": "Vidu Q3 Start/End Frame-to-Video Generation",
|
||||
"description": "Generate a video from a start frame, an end frame, and a prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Model to use for video generation."
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "first_frame"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt description (max 2000 characters)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"display_name": "Vidu Q3 Text-to-Video Generation",
|
||||
"description": "Generate video from a text prompt.",
|
||||
|
||||
@@ -285,8 +285,8 @@
|
||||
"name": "Show API node pricing badge"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Enable automatic node replacement",
|
||||
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
|
||||
"name": "Enable node replacement suggestions",
|
||||
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "Node search box implementation",
|
||||
@@ -350,6 +350,10 @@
|
||||
"name": "Batch count limit",
|
||||
"tooltip": "The maximum number of tasks added to the queue at one button click"
|
||||
},
|
||||
"Comfy_RightSidePanel_ShowErrorsTab": {
|
||||
"name": "Show errors tab in side panel",
|
||||
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
|
||||
},
|
||||
"Comfy_Sidebar_Location": {
|
||||
"name": "Sidebar location",
|
||||
"options": {
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "Salidas",
|
||||
"type": "Tipo"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Alternativas compatibles",
|
||||
"installMissingNodes": "Instalar nodos faltantes",
|
||||
"installationRequired": "Instalación requerida",
|
||||
"instructionMessage": "Debes instalar estos nodos o reemplazarlos por alternativas instaladas para ejecutar el flujo de trabajo. Los nodos faltantes están resaltados en {red} en el lienzo. Algunos nodos no se pueden intercambiar y deben instalarse mediante el Administrador de Nodos.",
|
||||
"notReplaceable": "Instalación requerida",
|
||||
"openNodeManager": "Abrir Administrador de Nodos",
|
||||
"quickFixAvailable": "Solución rápida disponible",
|
||||
"redHighlight": "rojo",
|
||||
"replaceFailed": "Error al reemplazar nodos",
|
||||
"replaceSelected": "Reemplazar seleccionados ({count})",
|
||||
"replaceWarning": "Esto modificará permanentemente el flujo de trabajo. Guarda una copia primero si no estás seguro.",
|
||||
"replaceable": "Reemplazable",
|
||||
"replaced": "Reemplazado",
|
||||
"replacedAllNodes": "Reemplazados {count} tipo(s) de nodo",
|
||||
"replacedNode": "Nodo reemplazado: {nodeType}",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"skipForNow": "Omitir por ahora"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Introduzca el nombre",
|
||||
"saveAsTemplate": "Guardar como plantilla"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "Más opciones",
|
||||
"noActiveJobs": "No hay trabajos activos",
|
||||
"preview": "Vista previa",
|
||||
"queuedJobsLabel": "{count} en cola",
|
||||
"queuedSuffix": "en cola",
|
||||
"running": "en ejecución",
|
||||
"runningJobsLabel": "{count} en ejecución",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"showAssets": "Mostrar recursos",
|
||||
"showAssetsPanel": "Mostrar panel de recursos",
|
||||
"sortBy": "Ordenar por",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Aplica la Guía de Atención Normalizada a los modelos, permitiendo prompts negativos en modelos distilled/schnell.",
|
||||
"display_name": "Guía de Atención Normalizada",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo al que se aplicará NAG."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Factor de mezcla para la atención normalizada. 1.0 es reemplazo total, 0.0 sin efecto."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "escala_nag",
|
||||
"tooltip": "El factor de escala de la guía. Valores más altos alejan más del prompt negativo."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "El modelo modificado con NAG habilitado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normalizar Imágenes",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Genera imágenes usando los modelos Recraft V4 o V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto a Imagen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo a utilizar para la generación."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamaño"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "El número de imágenes a generar."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Genera SVG usando los modelos Recraft V4 o V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto a Vector",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo a utilizar para la generación."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamaño"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "El número de imágenes a generar."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Genera SVG de forma sincrónica a partir de una imagen de entrada.",
|
||||
"display_name": "Recraft Vectorizar Imagen",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Genera un video a partir de un fotograma inicial, un fotograma final y un prompt.",
|
||||
"display_name": "Generación de video de inicio/fin de Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control después de generar"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "fotograma final"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "fotograma inicial"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Modelo a utilizar para la generación de video."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duración"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolución"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Descripción del prompt (máximo 2000 caracteres)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Genera un video a partir de un prompt de texto.",
|
||||
"display_name": "Generación de video de texto a video Vidu Q3",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "خروجیها",
|
||||
"type": "نوع"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "گزینههای سازگار",
|
||||
"installMissingNodes": "نصب نودهای مفقود",
|
||||
"installationRequired": "نصب مورد نیاز است",
|
||||
"instructionMessage": "برای اجرای workflow باید این نودها را نصب یا با گزینههای نصبشده جایگزین کنید. نودهای مفقود با رنگ {red} روی بوم مشخص شدهاند. برخی نودها قابل تعویض نیستند و باید از طریق Node Manager نصب شوند.",
|
||||
"notReplaceable": "نیاز به نصب",
|
||||
"openNodeManager": "باز کردن Node Manager",
|
||||
"quickFixAvailable": "رفع سریع در دسترس است",
|
||||
"redHighlight": "قرمز",
|
||||
"replaceFailed": "جایگزینی نودها ناموفق بود",
|
||||
"replaceSelected": "جایگزینی انتخابشدهها ({count})",
|
||||
"replaceWarning": "این کار workflow را به طور دائمی تغییر میدهد. اگر مطمئن نیستید، ابتدا یک نسخه ذخیره کنید.",
|
||||
"replaceable": "قابل جایگزینی",
|
||||
"replaced": "جایگزین شد",
|
||||
"replacedAllNodes": "{count} نوع نود جایگزین شد",
|
||||
"replacedNode": "نود جایگزین شد: {nodeType}",
|
||||
"selectAll": "انتخاب همه",
|
||||
"skipForNow": "فعلاً رد شود"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "نام را وارد کنید",
|
||||
"saveAsTemplate": "ذخیره به عنوان قالب"
|
||||
@@ -2425,8 +2444,11 @@
|
||||
"moreOptions": "گزینههای بیشتر",
|
||||
"noActiveJobs": "کار فعالی وجود ندارد",
|
||||
"preview": "پیشنمایش",
|
||||
"queuedJobsLabel": "{count} در صف",
|
||||
"queuedSuffix": "در صف",
|
||||
"running": "در حال اجرا",
|
||||
"runningJobsLabel": "{count} در حال اجرا",
|
||||
"runningQueuedSummary": "{running}، {queued}",
|
||||
"showAssets": "نمایش داراییها",
|
||||
"showAssetsPanel": "نمایش پنل داراییها",
|
||||
"sortBy": "مرتبسازی بر اساس",
|
||||
|
||||
@@ -10340,6 +10340,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "راهنمای توجه نرمالسازیشده را به مدلها اعمال میکند و امکان استفاده از پرامپت منفی را در مدلهای distilled/schnell فراهم میسازد.",
|
||||
"display_name": "راهنمای توجه نرمالسازیشده",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدلی که NAG بر روی آن اعمال میشود."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "ضریب ترکیب",
|
||||
"tooltip": "ضریب ترکیب برای توجه نرمالسازیشده. مقدار ۱.۰ به معنای جایگزینی کامل و ۰.۰ بدون تأثیر است."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "مقیاس راهنما",
|
||||
"tooltip": "ضریب مقیاس راهنما. مقادیر بالاتر فاصله بیشتری از پرامپت منفی ایجاد میکند."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "تاو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "مدل اصلاحشده با فعالسازی NAG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "نرمالسازی تصاویر",
|
||||
"inputs": {
|
||||
@@ -11714,6 +11740,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "تولید تصویر با استفاده از مدلهای Recraft V4 یا V4 Pro.",
|
||||
"display_name": "تبدیل متن به تصویر Recraft V4",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدل مورد استفاده برای تولید."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "اندازه"
|
||||
},
|
||||
"n": {
|
||||
"name": "تعداد",
|
||||
"tooltip": "تعداد تصاویر تولیدی."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "پرامپت منفی",
|
||||
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "کنترلهای Recraft",
|
||||
"tooltip": "کنترلهای اختیاری بیشتر بر تولید از طریق node کنترلهای Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "بذر",
|
||||
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرفنظر از بذر غیرقطعی هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "تولید SVG با استفاده از مدلهای Recraft V4 یا V4 Pro.",
|
||||
"display_name": "تبدیل متن به وکتور Recraft V4",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدل مورد استفاده برای تولید."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "اندازه"
|
||||
},
|
||||
"n": {
|
||||
"name": "تعداد",
|
||||
"tooltip": "تعداد تصاویر تولیدی."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "پرامپت منفی",
|
||||
"tooltip": "توضیح متنی اختیاری برای عناصر نامطلوب در تصویر."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت برای تولید تصویر. حداکثر ۱۰٬۰۰۰ کاراکتر."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "کنترلهای Recraft",
|
||||
"tooltip": "کنترلهای اختیاری بیشتر بر تولید از طریق node کنترلهای Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "بذر",
|
||||
"tooltip": "بذر برای تعیین اجرای مجدد node؛ نتایج واقعی صرفنظر از بذر غیرقطعی هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "تولید SVG به صورت همزمان از یک تصویر ورودی.",
|
||||
"display_name": "وکتورسازی تصویر Recraft",
|
||||
@@ -15859,6 +15967,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "تولید یک ویدیو از یک فریم آغازین، یک فریم پایانی و یک پرامپت.",
|
||||
"display_name": "تولید ویدیو از فریم آغازین/پایانی Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "فریم پایانی"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "فریم آغازین"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدلی که برای تولید ویدیو استفاده میشود."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "صدا"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "وضوح"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "توضیح پرامپت (حداکثر ۲۰۰۰ کاراکتر)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "بذر"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "تولید ویدیو از یک پرامپت متنی.",
|
||||
"display_name": "تولید ویدیو از متن Vidu Q3",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "Sorties",
|
||||
"type": "Type"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Alternatives compatibles",
|
||||
"installMissingNodes": "Installer les nœuds manquants",
|
||||
"installationRequired": "Installation requise",
|
||||
"instructionMessage": "Vous devez installer ces nœuds ou les remplacer par des alternatives installées pour exécuter le workflow. Les nœuds manquants sont surlignés en {red} sur le canevas. Certains nœuds ne peuvent pas être remplacés et doivent être installés via le Gestionnaire de nœuds.",
|
||||
"notReplaceable": "Installation requise",
|
||||
"openNodeManager": "Ouvrir le Gestionnaire de nœuds",
|
||||
"quickFixAvailable": "Correction rapide disponible",
|
||||
"redHighlight": "rouge",
|
||||
"replaceFailed": "Échec du remplacement des nœuds",
|
||||
"replaceSelected": "Remplacer la sélection ({count})",
|
||||
"replaceWarning": "Cela modifiera définitivement le workflow. Sauvegardez une copie si vous n’êtes pas sûr.",
|
||||
"replaceable": "Remplaçable",
|
||||
"replaced": "Remplacé",
|
||||
"replacedAllNodes": "{count} type(s) de nœud remplacé(s)",
|
||||
"replacedNode": "Nœud remplacé : {nodeType}",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"skipForNow": "Ignorer pour l’instant"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Entrez le nom",
|
||||
"saveAsTemplate": "Enregistrer comme modèle"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "Plus d’options",
|
||||
"noActiveJobs": "Aucun travail actif",
|
||||
"preview": "Aperçu",
|
||||
"queuedJobsLabel": "{count} en file d’attente",
|
||||
"queuedSuffix": "en file d’attente",
|
||||
"running": "en cours",
|
||||
"runningJobsLabel": "{count} en cours",
|
||||
"runningQueuedSummary": "{running} en cours, {queued} en file",
|
||||
"showAssets": "Afficher les ressources",
|
||||
"showAssetsPanel": "Afficher le panneau des ressources",
|
||||
"sortBy": "Trier par",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Applique le Guidage d’Attention Normalisée aux modèles, permettant l’utilisation de prompts négatifs sur les modèles distilled/schnell.",
|
||||
"display_name": "Guidage d’Attention Normalisée",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle auquel appliquer NAG."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "alpha_nag",
|
||||
"tooltip": "Facteur de fusion pour l’attention normalisée. 1,0 correspond à un remplacement total, 0,0 à aucun effet."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "facteur_nag",
|
||||
"tooltip": "Le facteur d’échelle du guidage. Des valeurs plus élevées éloignent davantage du prompt négatif."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "tau_nag"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "Le modèle modifié avec NAG activé."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normaliser les images",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Génère des images à l’aide des modèles Recraft V4 ou V4 Pro.",
|
||||
"display_name": "Recraft V4 Texte vers Image",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle à utiliser pour la génération."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "taille"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Nombre d’images à générer."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_négatif",
|
||||
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt pour la génération d’image. Maximum 10 000 caractères."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Génère des SVG à l’aide des modèles Recraft V4 ou V4 Pro.",
|
||||
"display_name": "Recraft V4 Texte vers Vectoriel",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle à utiliser pour la génération."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "taille"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Nombre d’images à générer."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_négatif",
|
||||
"tooltip": "Description textuelle optionnelle des éléments indésirables sur une image."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt pour la génération d’image. Maximum 10 000 caractères."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Contrôles supplémentaires optionnels sur la génération via le nœud Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit la graine."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Génère un SVG de manière synchrone à partir d'une image d'entrée.",
|
||||
"display_name": "Vectoriser une image avec Recraft",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Générez une vidéo à partir d'une image de début, d'une image de fin et d'une invite.",
|
||||
"display_name": "Génération vidéo Vidu Q3 à partir d'une image de début/fin",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "image de fin"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "image de début"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Modèle à utiliser pour la génération vidéo."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "durée"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "invite",
|
||||
"tooltip": "Description de l'invite (2000 caractères max)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Générez une vidéo à partir d’une invite textuelle.",
|
||||
"display_name": "Génération de vidéo à partir de texte Vidu Q3",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "出力",
|
||||
"type": "タイプ"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "互換性のある代替案",
|
||||
"installMissingNodes": "不足ノードをインストール",
|
||||
"installationRequired": "インストールが必要",
|
||||
"instructionMessage": "ワークフローを実行するには、これらのノードをインストールするか、インストール済みの代替ノードに置き換える必要があります。足りないノードはキャンバス上で{red}でハイライトされています。一部のノードは置き換えできず、Node Managerからインストールする必要があります。",
|
||||
"notReplaceable": "インストールが必要",
|
||||
"openNodeManager": "Node Managerを開く",
|
||||
"quickFixAvailable": "クイック修正可能",
|
||||
"redHighlight": "赤",
|
||||
"replaceFailed": "ノードの置き換えに失敗しました",
|
||||
"replaceSelected": "選択したものを置き換え ({count})",
|
||||
"replaceWarning": "この操作はワークフローを永久に変更します。心配な場合は、先にコピーを保存してください。",
|
||||
"replaceable": "置き換え可能",
|
||||
"replaced": "置き換え済み",
|
||||
"replacedAllNodes": "{count} 種類のノードを置き換えました",
|
||||
"replacedNode": "置き換えたノード: {nodeType}",
|
||||
"selectAll": "すべて選択",
|
||||
"skipForNow": "今はスキップ"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "名前を入力",
|
||||
"saveAsTemplate": "テンプレートとして保存"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "その他のオプション",
|
||||
"noActiveJobs": "アクティブなジョブはありません",
|
||||
"preview": "プレビュー",
|
||||
"queuedJobsLabel": "{count} キュー中",
|
||||
"queuedSuffix": "キュー済み",
|
||||
"running": "実行中",
|
||||
"runningJobsLabel": "{count} 実行中",
|
||||
"runningQueuedSummary": "{running} 実行中、{queued} キュー中",
|
||||
"showAssets": "アセットを表示",
|
||||
"showAssetsPanel": "アセットパネルを表示",
|
||||
"sortBy": "並べ替え条件",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "モデルに正規化アテンションガイダンスを適用し、distilled/schnellモデルでネガティブプロンプトを有効にします。",
|
||||
"display_name": "正規化アテンションガイダンス",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "NAGを適用するモデル。"
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "正規化アテンションのブレンド係数。1.0は完全な置換、0.0は効果なし。"
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "ガイダンスのスケール係数。値が高いほどネガティブプロンプトからさらに離れます。"
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "NAGが有効化されたパッチ済みモデル。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "画像を正規化",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Recraft V4またはV4 Proモデルを使用して画像を生成します。",
|
||||
"display_name": "Recraft V4 テキストから画像生成",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後のコントロール"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "生成に使用するモデル。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "サイズ"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "生成する画像の枚数。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controlsノードによる追加の生成コントロール(任意)。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Recraft V4またはV4 Proモデルを使用してSVGを生成します。",
|
||||
"display_name": "Recraft V4 テキストからベクター生成",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後のコントロール"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "生成に使用するモデル。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "サイズ"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "生成する画像の枚数。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "画像に含めたくない要素のテキスト説明(任意)。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "画像生成用のプロンプト。最大10,000文字。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controlsノードによる追加の生成コントロール(任意)。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "入力画像からSVGを同期的に生成します。",
|
||||
"display_name": "Recraft ベクトル化画像",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "開始フレーム、終了フレーム、およびプロンプトから動画を生成します。",
|
||||
"display_name": "Vidu Q3 開始/終了フレームからの動画生成",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後のコントロール"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "終了フレーム"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "開始フレーム"
|
||||
},
|
||||
"model": {
|
||||
"name": "モデル",
|
||||
"tooltip": "動画生成に使用するモデル。"
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "オーディオ"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "再生時間"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "解像度"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "プロンプト",
|
||||
"tooltip": "プロンプトの説明(最大2000文字)。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "シード"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "テキストプロンプトから動画を生成します。",
|
||||
"display_name": "Vidu Q3 テキストから動画生成",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "출력",
|
||||
"type": "유형"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "호환 가능한 대안",
|
||||
"installMissingNodes": "누락된 노드 설치",
|
||||
"installationRequired": "설치 필요",
|
||||
"instructionMessage": "워크플로를 실행하려면 이 노드를 설치하거나 설치된 대안으로 교체해야 합니다. 누락된 노드는 캔버스에서 {red}로 강조 표시됩니다. 일부 노드는 교체할 수 없으므로 Node Manager를 통해 설치해야 합니다.",
|
||||
"notReplaceable": "설치 필요",
|
||||
"openNodeManager": "Node Manager 열기",
|
||||
"quickFixAvailable": "빠른 수정 가능",
|
||||
"redHighlight": "빨간색",
|
||||
"replaceFailed": "노드 교체 실패",
|
||||
"replaceSelected": "선택한 항목 교체 ({count})",
|
||||
"replaceWarning": "이 작업은 워크플로를 영구적으로 수정합니다. 확실하지 않으면 먼저 복사본을 저장하세요.",
|
||||
"replaceable": "교체 가능",
|
||||
"replaced": "교체됨",
|
||||
"replacedAllNodes": "{count}개 노드 유형 교체됨",
|
||||
"replacedNode": "교체된 노드: {nodeType}",
|
||||
"selectAll": "전체 선택",
|
||||
"skipForNow": "일단 건너뛰기"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "이름 입력",
|
||||
"saveAsTemplate": "템플릿으로 저장"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "더 많은 옵션",
|
||||
"noActiveJobs": "활성 작업 없음",
|
||||
"preview": "미리보기",
|
||||
"queuedJobsLabel": "{count}개 대기 중",
|
||||
"queuedSuffix": "대기 중",
|
||||
"running": "실행 중",
|
||||
"runningJobsLabel": "{count}개 실행 중",
|
||||
"runningQueuedSummary": "{running} 실행 중, {queued} 대기 중",
|
||||
"showAssets": "에셋 보기",
|
||||
"showAssetsPanel": "에셋 패널 보기",
|
||||
"sortBy": "정렬 기준",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "정규화된 어텐션 가이던스를 모델에 적용하여, distilled/schnell 모델에서 네거티브 프롬프트를 사용할 수 있게 합니다.",
|
||||
"display_name": "정규화된 어텐션 가이던스",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "NAG를 적용할 모델입니다."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "정규화된 어텐션의 블렌딩 계수입니다. 1.0은 완전 대체, 0.0은 효과 없음입니다."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "가이던스 스케일 계수입니다. 값이 높을수록 네거티브 프롬프트에서 더 멀어집니다."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "NAG가 활성화된 패치된 모델입니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "이미지 정규화",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 이미지를 생성합니다.",
|
||||
"display_name": "Recraft V4 텍스트-이미지",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "생성에 사용할 모델입니다."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "생성할 이미지의 개수입니다."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Recraft V4 또는 V4 Pro 모델을 사용하여 SVG를 생성합니다.",
|
||||
"display_name": "Recraft V4 텍스트-벡터",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "생성에 사용할 모델입니다."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "생성할 이미지의 개수입니다."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "이미지에서 원하지 않는 요소에 대한 선택적 텍스트 설명입니다."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "이미지 생성을 위한 프롬프트입니다. 최대 10,000자까지 입력 가능합니다."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controls 노드를 통한 추가 생성 제어(선택 사항)입니다."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "입력 이미지로부터 SVG를 동기적으로 생성합니다.",
|
||||
"display_name": "Recraft 벡터 생성 (이미지 → 벡터)",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "시작 프레임, 종료 프레임, 프롬프트를 사용하여 비디오를 생성합니다.",
|
||||
"display_name": "Vidu Q3 시작/종료 프레임-투-비디오 생성",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "생성 후 제어"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "종료 프레임"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "시작 프레임"
|
||||
},
|
||||
"model": {
|
||||
"name": "모델",
|
||||
"tooltip": "비디오 생성을 위해 사용할 모델입니다."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "오디오"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "길이"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "해상도"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "프롬프트",
|
||||
"tooltip": "프롬프트 설명 (최대 2000자)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "시드"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "텍스트 프롬프트로부터 비디오를 생성합니다.",
|
||||
"display_name": "Vidu Q3 텍스트-비디오 생성",
|
||||
|
||||
@@ -249,8 +249,8 @@
|
||||
"name": "API 노드 가격 배지 표시"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "자동 노드 교체 활성화",
|
||||
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
|
||||
"name": "노드 교체 제안 활성화",
|
||||
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "노드 검색 상자 구현",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "Saídas",
|
||||
"type": "Tipo"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Alternativas Compatíveis",
|
||||
"installMissingNodes": "Instalar Nós Ausentes",
|
||||
"installationRequired": "Instalação Necessária",
|
||||
"instructionMessage": "Você deve instalar esses nós ou substituí-los por alternativas já instaladas para executar o fluxo de trabalho. Nós ausentes estão destacados em {red} na tela. Alguns nós não podem ser trocados e devem ser instalados pelo Gerenciador de Nós.",
|
||||
"notReplaceable": "Instalação Necessária",
|
||||
"openNodeManager": "Abrir Gerenciador de Nós",
|
||||
"quickFixAvailable": "Correção Rápida Disponível",
|
||||
"redHighlight": "vermelho",
|
||||
"replaceFailed": "Falha ao substituir nós",
|
||||
"replaceSelected": "Substituir Selecionados ({count})",
|
||||
"replaceWarning": "Isso modificará permanentemente o fluxo de trabalho. Salve uma cópia antes se não tiver certeza.",
|
||||
"replaceable": "Substituível",
|
||||
"replaced": "Substituído",
|
||||
"replacedAllNodes": "Substituídos {count} tipo(s) de nó",
|
||||
"replacedNode": "Nó substituído: {nodeType}",
|
||||
"selectAll": "Selecionar Tudo",
|
||||
"skipForNow": "Pular por enquanto"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Digite o nome",
|
||||
"saveAsTemplate": "Salvar como modelo"
|
||||
@@ -2425,8 +2444,11 @@
|
||||
"moreOptions": "Mais opções",
|
||||
"noActiveJobs": "Nenhum trabalho ativo",
|
||||
"preview": "Pré-visualização",
|
||||
"queuedJobsLabel": "{count} na fila",
|
||||
"queuedSuffix": "na fila",
|
||||
"running": "executando",
|
||||
"runningJobsLabel": "{count} em execução",
|
||||
"runningQueuedSummary": "{running} em execução, {queued} na fila",
|
||||
"showAssets": "Mostrar ativos",
|
||||
"showAssetsPanel": "Mostrar painel de ativos",
|
||||
"sortBy": "Ordenar por",
|
||||
|
||||
@@ -10340,6 +10340,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Aplica Orientação de Atenção Normalizada aos modelos, permitindo prompts negativos em modelos distilled/schnell.",
|
||||
"display_name": "Orientação de Atenção Normalizada",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "O modelo ao qual aplicar o NAG."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Fator de mesclagem para a atenção normalizada. 1.0 é substituição total, 0.0 não tem efeito."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "escala_nag",
|
||||
"tooltip": "O fator de escala da orientação. Valores mais altos afastam mais do prompt negativo."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "O modelo modificado com NAG ativado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normalizar Imagens",
|
||||
"inputs": {
|
||||
@@ -11714,6 +11740,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Gera imagens usando os modelos Recraft V4 ou V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto para Imagem",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controle após gerar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "O modelo a ser usado para geração."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamanho"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "O número de imagens a serem geradas."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semente",
|
||||
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Gera SVG usando os modelos Recraft V4 ou V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto para Vetor",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controle após gerar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "O modelo a ser usado para geração."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamanho"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "O número de imagens a serem geradas."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Uma descrição opcional em texto de elementos indesejados em uma imagem."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para a geração da imagem. Máximo de 10.000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionais opcionais sobre a geração via o nó Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semente",
|
||||
"tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Gera SVG de forma síncrona a partir de uma imagem de entrada.",
|
||||
"display_name": "Recraft Vetorizar Imagem",
|
||||
@@ -15859,6 +15967,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Gere um vídeo a partir de um quadro inicial, um quadro final e um prompt.",
|
||||
"display_name": "Geração de Vídeo Quadro Inicial/Final Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controle após gerar"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "quadro final"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "quadro inicial"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Modelo a ser usado para geração de vídeo."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "áudio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duração"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolução"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Descrição do prompt (máx. 2000 caracteres)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semente"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Gere um vídeo a partir de um prompt de texto.",
|
||||
"display_name": "Geração de Vídeo de Texto para Vídeo Vidu Q3",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "Выходы",
|
||||
"type": "Тип"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Совместимые альтернативы",
|
||||
"installMissingNodes": "Установить отсутствующие узлы",
|
||||
"installationRequired": "Требуется установка",
|
||||
"instructionMessage": "Вам необходимо установить эти узлы или заменить их установленными альтернативами, чтобы запустить рабочий процесс. Отсутствующие узлы выделены {red} на холсте. Некоторые узлы нельзя заменить, их нужно установить через Менеджер узлов.",
|
||||
"notReplaceable": "Требуется установка",
|
||||
"openNodeManager": "Открыть Менеджер узлов",
|
||||
"quickFixAvailable": "Доступно быстрое исправление",
|
||||
"redHighlight": "красным",
|
||||
"replaceFailed": "Не удалось заменить узлы",
|
||||
"replaceSelected": "Заменить выбранные ({count})",
|
||||
"replaceWarning": "Это действие навсегда изменит рабочий процесс. Сохраните копию, если не уверены.",
|
||||
"replaceable": "Можно заменить",
|
||||
"replaced": "Заменено",
|
||||
"replacedAllNodes": "Заменено {count} типов(а) узлов",
|
||||
"replacedNode": "Заменённый узел: {nodeType}",
|
||||
"selectAll": "Выбрать все",
|
||||
"skipForNow": "Пропустить сейчас"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Введите название",
|
||||
"saveAsTemplate": "Сохранить как шаблон"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "Больше опций",
|
||||
"noActiveJobs": "Нет активных заданий",
|
||||
"preview": "Предпросмотр",
|
||||
"queuedJobsLabel": "{count} в очереди",
|
||||
"queuedSuffix": "в очереди",
|
||||
"running": "выполняется",
|
||||
"runningJobsLabel": "{count} выполняется",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"showAssets": "Показать ассеты",
|
||||
"showAssetsPanel": "Показать панель ассетов",
|
||||
"sortBy": "Сортировать по",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Применяет нормализованное управление вниманием к моделям, позволяя использовать негативные подсказки на distilled/schnell моделях.",
|
||||
"display_name": "Нормализованное управление вниманием",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Модель, к которой применяется NAG."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Коэффициент смешивания для нормализованного внимания. 1.0 — полная замена, 0.0 — без эффекта."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "Коэффициент масштаба управления. Более высокие значения сильнее отдаляют от негативной подсказки."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "Патченная модель с включённым NAG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Нормализовать изображения",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Генерирует изображения с помощью моделей Recraft V4 или V4 Pro.",
|
||||
"display_name": "Recraft V4: текст в изображение",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Модель, используемая для генерации."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Количество изображений для генерации."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Необязательное текстовое описание нежелательных элементов на изображении."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Подсказка для генерации изображения. Максимум 10 000 символов."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Необязательные дополнительные параметры управления генерацией через узел Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты не детерминированы независимо от сида."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Генерирует SVG с помощью моделей Recraft V4 или V4 Pro.",
|
||||
"display_name": "Recraft V4: текст в вектор",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Модель, используемая для генерации."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Количество изображений для генерации."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Необязательное текстовое описание нежелательных элементов на изображении."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Подсказка для генерации изображения. Максимум 10 000 символов."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Необязательные дополнительные параметры управления генерацией через узел Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты не детерминированы независимо от сида."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Генерирует SVG синхронно из входного изображения.",
|
||||
"display_name": "Recraft Векторизация Изображения",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Создайте видео, используя начальный кадр, конечный кадр и текстовый запрос.",
|
||||
"display_name": "Vidu Q3 Генерация видео по начальному и конечному кадрам",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "контроль после генерации"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "конечный кадр"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "начальный кадр"
|
||||
},
|
||||
"model": {
|
||||
"name": "модель",
|
||||
"tooltip": "Модель для генерации видео."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "аудио"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "длительность"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "разрешение"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "промпт",
|
||||
"tooltip": "Описание промпта (максимум 2000 символов)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Создайте видео по текстовой подсказке.",
|
||||
"display_name": "Vidu Q3: генерация видео по тексту",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "Çıktılar",
|
||||
"type": "Tür"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Uyumlu Alternatifler",
|
||||
"installMissingNodes": "Eksik Düğümleri Yükle",
|
||||
"installationRequired": "Kurulum Gerekli",
|
||||
"instructionMessage": "İş akışını çalıştırmak için bu düğümleri yüklemeli veya yüklü alternatiflerle değiştirmelisiniz. Eksik düğümler tuvalde {red} ile vurgulanır. Bazı düğümler değiştirilemez ve Node Manager üzerinden yüklenmelidir.",
|
||||
"notReplaceable": "Kurulum Gerekli",
|
||||
"openNodeManager": "Node Manager'ı Aç",
|
||||
"quickFixAvailable": "Hızlı Düzeltme Mevcut",
|
||||
"redHighlight": "kırmızı",
|
||||
"replaceFailed": "Düğümler değiştirilemedi",
|
||||
"replaceSelected": "Seçilenleri Değiştir ({count})",
|
||||
"replaceWarning": "Bu işlem iş akışını kalıcı olarak değiştirecek. Emin değilseniz önce bir kopyasını kaydedin.",
|
||||
"replaceable": "Değiştirilebilir",
|
||||
"replaced": "Değiştirildi",
|
||||
"replacedAllNodes": "{count} düğüm türü değiştirildi",
|
||||
"replacedNode": "Değiştirilen düğüm: {nodeType}",
|
||||
"selectAll": "Tümünü Seç",
|
||||
"skipForNow": "Şimdilik Atla"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "İsim girin",
|
||||
"saveAsTemplate": "Şablon olarak kaydet"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "Daha fazla seçenek",
|
||||
"noActiveJobs": "Aktif iş yok",
|
||||
"preview": "Önizleme",
|
||||
"queuedJobsLabel": "{count} kuyruğa alındı",
|
||||
"queuedSuffix": "kuyrukta",
|
||||
"running": "çalışıyor",
|
||||
"runningJobsLabel": "{count} çalışıyor",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"showAssets": "Varlıkları göster",
|
||||
"showAssetsPanel": "Varlık panelini göster",
|
||||
"sortBy": "Sırala",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Modellere Normalize Edilmiş Dikkat Yönlendirmesi uygular, distilled/schnell modellerde negatif istemlere olanak tanır.",
|
||||
"display_name": "Normalize Edilmiş Dikkat Yönlendirmesi",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "NAG uygulanacak model."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Normalize edilmiş dikkat için karıştırma faktörü. 1.0 tam değişim, 0.0 hiçbir etki yok."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "Yönlendirme ölçek faktörü. Daha yüksek değerler, negatif istemden daha fazla uzaklaştırır."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "NAG etkinleştirilmiş yamalı model."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Görüntüleri Normalleştir",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Recraft V4 veya V4 Pro modelleriyle görseller üretir.",
|
||||
"display_name": "Recraft V4 Metinden Görsele",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "üretimden sonra kontrol"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Üretim için kullanılacak model."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "boyut"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Üretilecek görsel sayısı."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Bir görselde istenmeyen ögelerin isteğe bağlı metin açıklaması."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Görsel üretimi için istem. En fazla 10.000 karakter."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controls düğümüyle üretim üzerinde isteğe bağlı ek kontroller."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; tohuma bakılmaksızın gerçek sonuçlar deterministik değildir."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Recraft V4 veya V4 Pro modelleriyle SVG üretir.",
|
||||
"display_name": "Recraft V4 Metinden Vektöre",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "üretimden sonra kontrol"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Üretim için kullanılacak model."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "boyut"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "Üretilecek görsel sayısı."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Bir görselde istenmeyen ögelerin isteğe bağlı metin açıklaması."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Görsel üretimi için istem. En fazla 10.000 karakter."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Recraft Controls düğümüyle üretim üzerinde isteğe bağlı ek kontroller."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; tohuma bakılmaksızın gerçek sonuçlar deterministik değildir."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Bir giriş görüntüsünden eşzamanlı olarak SVG oluşturur.",
|
||||
"display_name": "Recraft Görüntüyü Vektörleştir",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Bir başlangıç karesi, bir bitiş karesi ve bir komut istemi ile video oluşturun.",
|
||||
"display_name": "Vidu Q3 Başlangıç/Bitiş Kareden Videoya Oluşturma",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "oluşturduktan sonra kontrol et"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "bitiş karesi"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "ilk kare"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Video oluşturmak için kullanılacak model."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "ses"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "süre"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "çözünürlük"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "komut istemi",
|
||||
"tooltip": "Komut istemi açıklaması (en fazla 2000 karakter)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "tohum"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Bir metin isteminden video oluşturun.",
|
||||
"display_name": "Vidu Q3 Metinden Videoya Üretim",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "輸出",
|
||||
"type": "類型"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "相容替代方案",
|
||||
"installMissingNodes": "安裝缺少的節點",
|
||||
"installationRequired": "需要安裝",
|
||||
"instructionMessage": "您必須安裝這些節點,或以已安裝的替代方案進行替換,才能執行工作流程。缺少的節點會在畫布上以{red}標示。有些節點無法替換,必須透過節點管理器安裝。",
|
||||
"notReplaceable": "需要安裝",
|
||||
"openNodeManager": "開啟節點管理器",
|
||||
"quickFixAvailable": "可用快速修復",
|
||||
"redHighlight": "紅色",
|
||||
"replaceFailed": "替換節點失敗",
|
||||
"replaceSelected": "替換所選 ({count})",
|
||||
"replaceWarning": "這將永久修改工作流程。如有疑慮,請先儲存副本。",
|
||||
"replaceable": "可替換",
|
||||
"replaced": "已替換",
|
||||
"replacedAllNodes": "已替換 {count} 種節點類型",
|
||||
"replacedNode": "已替換節點:{nodeType}",
|
||||
"selectAll": "全選",
|
||||
"skipForNow": "暫時略過"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "輸入名稱",
|
||||
"saveAsTemplate": "儲存為範本"
|
||||
@@ -2414,8 +2433,11 @@
|
||||
"moreOptions": "更多選項",
|
||||
"noActiveJobs": "沒有執行中作業",
|
||||
"preview": "預覽",
|
||||
"queuedJobsLabel": "{count} 已排隊",
|
||||
"queuedSuffix": "已排入佇列",
|
||||
"running": "執行中",
|
||||
"runningJobsLabel": "{count} 執行中",
|
||||
"runningQueuedSummary": "{running} 執行中, {queued} 已排隊",
|
||||
"showAssets": "顯示資產",
|
||||
"showAssetsPanel": "顯示資產面板",
|
||||
"sortBy": "排序依據",
|
||||
|
||||
@@ -10338,6 +10338,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "對模型應用標準化注意力引導,讓蒸餾/schnell 模型支援負面提示。",
|
||||
"display_name": "標準化注意力引導",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "要套用 NAG 的模型。"
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "標準化注意力的混合係數。1.0 為完全取代,0.0 則無效果。"
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "引導強度係數。數值越高,越遠離負面提示。"
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "已啟用 NAG 的修補模型。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "標準化圖片",
|
||||
"inputs": {
|
||||
@@ -11712,6 +11738,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "使用 Recraft V4 或 V4 Pro 模型產生圖像。",
|
||||
"display_name": "Recraft V4 文字轉圖像",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "用於生成的模型。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "要生成的圖像數量。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "可選的圖像中不希望出現元素的文字描述。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "圖像生成提示語。最多 10,000 字元。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "可選,透過 Recraft Controls 節點進行額外生成控制。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不保證可重現。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "使用 Recraft V4 或 V4 Pro 模型產生 SVG。",
|
||||
"display_name": "Recraft V4 文字轉向量圖",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "用於生成的模型。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "要生成的圖像數量。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "可選的圖像中不希望出現元素的文字描述。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "圖像生成提示語。最多 10,000 字元。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "可選,透過 Recraft Controls 節點進行額外生成控制。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不保證可重現。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "從輸入圖片同步產生 SVG。",
|
||||
"display_name": "Recraft 向量化圖片",
|
||||
@@ -15846,6 +15954,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "根據起始影格、結束影格與提示詞生成影片。",
|
||||
"display_name": "Vidu Q3 起始/結束影格轉影片生成",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後控制"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "結束影格"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "起始影格"
|
||||
},
|
||||
"model": {
|
||||
"name": "模型",
|
||||
"tooltip": "用於影片生成的模型。"
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "音訊"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "時長"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "解析度"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "提示詞",
|
||||
"tooltip": "提示描述(最多 2000 字元)。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "隨機種子"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "根據文字提示生成影片。",
|
||||
"display_name": "Vidu Q3 文字轉影片生成",
|
||||
|
||||
@@ -1898,6 +1898,25 @@
|
||||
"outputs": "输出",
|
||||
"type": "类型"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "兼容替代项",
|
||||
"installMissingNodes": "安装缺失节点",
|
||||
"installationRequired": "需要安装",
|
||||
"instructionMessage": "您必须安装这些节点或用已安装的替代项替换它们,才能运行工作流。缺失的节点会在画布上以{red}高亮显示。有些节点无法替换,必须通过节点管理器安装。",
|
||||
"notReplaceable": "需要安装",
|
||||
"openNodeManager": "打开节点管理器",
|
||||
"quickFixAvailable": "可用快速修复",
|
||||
"redHighlight": "红色",
|
||||
"replaceFailed": "替换节点失败",
|
||||
"replaceSelected": "替换已选({count})",
|
||||
"replaceWarning": "此操作将永久修改工作流。如不确定,请先保存副本。",
|
||||
"replaceable": "可替换",
|
||||
"replaced": "已替换",
|
||||
"replacedAllNodes": "已替换 {count} 种节点类型",
|
||||
"replacedNode": "已替换节点:{nodeType}",
|
||||
"selectAll": "全选",
|
||||
"skipForNow": "暂时跳过"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "输入名称",
|
||||
"saveAsTemplate": "另存为模板"
|
||||
@@ -2425,8 +2444,11 @@
|
||||
"moreOptions": "更多设置",
|
||||
"noActiveJobs": "无活跃任务",
|
||||
"preview": "预览",
|
||||
"queuedJobsLabel": "{count} 个已排队",
|
||||
"queuedSuffix": "已执行",
|
||||
"running": "运行中",
|
||||
"runningJobsLabel": "{count} 个正在运行",
|
||||
"runningQueuedSummary": "{running},{queued}",
|
||||
"showAssets": "显示资产",
|
||||
"showAssetsPanel": "显示资产面板",
|
||||
"sortBy": "排序方式",
|
||||
|
||||
@@ -10340,6 +10340,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "对模型应用归一化注意力引导,使蒸馏/快速模型支持负向提示。",
|
||||
"display_name": "归一化注意力引导",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "要应用NAG的模型。"
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "归一化注意力的混合因子。1.0为完全替换,0.0为无效果。"
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "引导缩放因子。数值越大,越远离负向提示。"
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "已启用NAG的修补模型。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "规格化图像",
|
||||
"inputs": {
|
||||
@@ -11714,6 +11740,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "使用Recraft V4或V4 Pro模型生成图像。",
|
||||
"display_name": "Recraft V4 文本转图像",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "用于生成的模型。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "要生成的图像数量。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "可选的图像中不希望出现元素的文本描述。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "用于图像生成的提示词。最多10,000个字符。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "通过Recraft Controls节点对生成过程的可选附加控制。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "使用Recraft V4或V4 Pro模型生成SVG。",
|
||||
"display_name": "Recraft V4 文本转矢量",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "用于生成的模型。"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "要生成的图像数量。"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "可选的图像中不希望出现元素的文本描述。"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "用于图像生成的提示词。最多10,000个字符。"
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "通过Recraft Controls节点对生成过程的可选附加控制。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "从输入图像同步生成 SVG。",
|
||||
"display_name": "Recraft 矢量化图像",
|
||||
@@ -15859,6 +15967,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "根据起始帧、结束帧和提示词生成视频。",
|
||||
"display_name": "Vidu Q3 起始/结束帧到视频生成",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成后控制"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "结束帧"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "起始帧"
|
||||
},
|
||||
"model": {
|
||||
"name": "模型",
|
||||
"tooltip": "用于视频生成的模型。"
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "音频"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "时长"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "分辨率"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "提示词",
|
||||
"tooltip": "提示描述(最多2000字符)。"
|
||||
},
|
||||
"seed": {
|
||||
"name": "种子"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "根据文本提示生成视频。",
|
||||
"display_name": "Vidu Q3 文本转视频生成",
|
||||
|
||||
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal file
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetExport } from '@/stores/assetExportStore'
|
||||
import { useAssetExportStore } from '@/stores/assetExportStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
const visible = computed(() => assetExportStore.hasExports)
|
||||
const isExpanded = ref(false)
|
||||
|
||||
const exportJobs = computed(() => assetExportStore.exportList)
|
||||
const failedJobs = computed(() =>
|
||||
assetExportStore.finishedExports.filter((e) => e.status === 'failed')
|
||||
)
|
||||
|
||||
const isInProgress = computed(() => assetExportStore.hasActiveExports)
|
||||
const currentJobName = computed(() => {
|
||||
const activeJob = exportJobs.value.find((job) => job.status === 'running')
|
||||
return activeJob?.exportName || t('exportToast.preparingExport')
|
||||
})
|
||||
|
||||
function jobDisplayName(job: AssetExport): string {
|
||||
if (job.status === 'failed') return job.error || t('exportToast.exportError')
|
||||
return job.exportName || t('exportToast.preparingExport')
|
||||
}
|
||||
|
||||
const completedCount = computed(() => assetExportStore.finishedExports.length)
|
||||
const totalCount = computed(() => exportJobs.value.length)
|
||||
|
||||
const footerLabel = computed(() => {
|
||||
if (isInProgress.value) return currentJobName.value
|
||||
if (failedJobs.value.length > 0)
|
||||
return t('exportToast.exportFailed', { count: failedJobs.value.length })
|
||||
return t('exportToast.allExportsCompleted')
|
||||
})
|
||||
|
||||
const footerIconClass = computed(() => {
|
||||
if (isInProgress.value)
|
||||
return 'icon-[lucide--loader-circle] animate-spin text-muted-foreground'
|
||||
if (failedJobs.value.length > 0)
|
||||
return 'icon-[lucide--circle-alert] text-destructive-background'
|
||||
return 'icon-[lucide--check-circle] text-jade-600'
|
||||
})
|
||||
|
||||
const tooltipConfig = computed(() => ({
|
||||
value: footerLabel.value,
|
||||
disabled: isExpanded.value,
|
||||
pt: { root: { class: 'z-10000!' } }
|
||||
}))
|
||||
|
||||
function progressPercent(job: AssetExport): number {
|
||||
return Math.round(job.progress * 100)
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
assetExportStore.clearFinishedExports()
|
||||
isExpanded.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible>
|
||||
<template #default>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-base-foreground">
|
||||
{{ t('exportToast.exportingAssets') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="job in exportJobs"
|
||||
:key="job.taskId"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
job.status === 'completed' && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'block truncate text-sm',
|
||||
job.status === 'failed'
|
||||
? 'text-destructive-background'
|
||||
: 'text-base-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ jobDisplayName(job) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="job.assetsTotal > 0"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ job.assetsAttempted }}/{{ job.assetsTotal }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="job.status === 'failed'">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="job.status === 'completed' && job.downloadError"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-destructive-background truncate max-w-32"
|
||||
>
|
||||
{{ job.downloadError }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('exportToast.retryDownload')"
|
||||
@click.stop="assetExportStore.triggerDownload(job, true)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--rotate-ccw] size-4 text-destructive-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="job.status === 'completed'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('exportToast.downloadExport')"
|
||||
@click.stop="assetExportStore.triggerDownload(job, true)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--download] size-4 text-success-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="job.status === 'running'">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent(job) }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('progressToast.pending') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="exportJobs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-center"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t('exportToast.noExportsInQueue', {
|
||||
filter: t('progressToast.filter.all')
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div
|
||||
class="flex flex-1 min-w-0 h-12 items-center justify-between gap-2 border-t border-border-default px-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<i
|
||||
v-tooltip.top="tooltipConfig"
|
||||
:class="cn('size-4 shrink-0', footerIconClass)"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'truncate font-bold text-base-foreground transition-all duration-300 overflow-hidden',
|
||||
isExpanded ? 'min-w-0 flex-1' : 'w-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ footerLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="isInProgress"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm text-muted-foreground transition-all duration-300 overflow-hidden',
|
||||
isExpanded ? 'whitespace-nowrap' : 'w-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
t('progressToast.progressCount', {
|
||||
completed: completedCount,
|
||||
total: totalCount
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
|
||||
"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isExpanded
|
||||
? 'icon-[lucide--chevron-down]'
|
||||
: 'icon-[lucide--chevron-up]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="!isInProgress"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="closeDialog"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<MultiSelect
|
||||
v-if="availableFileFormats.length > 0"
|
||||
v-model="fileFormats"
|
||||
v-model="activeFileFormatObjects"
|
||||
:label="$t('assetBrowser.fileFormats')"
|
||||
:options="availableFileFormats"
|
||||
class="min-w-32"
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<MultiSelect
|
||||
v-if="availableBaseModels.length > 0"
|
||||
v-model="baseModels"
|
||||
v-model="activeBaseModelObjects"
|
||||
:label="$t('assetBrowser.baseModels')"
|
||||
:options="availableBaseModels"
|
||||
class="min-w-32"
|
||||
@@ -83,22 +83,45 @@ const { assets = [], showOwnershipFilter = false } = defineProps<{
|
||||
showOwnershipFilter?: boolean
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const selectedFileFormats = ref<SelectOption[]>([])
|
||||
const selectedBaseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref<AssetSortOption>('recent')
|
||||
const ownership = ref<OwnershipOption>('all')
|
||||
|
||||
const { availableFileFormats, availableBaseModels, ownershipOptions } =
|
||||
useAssetFilterOptions(() => assets)
|
||||
|
||||
// Only show selected items that exist in the current scope
|
||||
const activeFileFormatObjects = computed({
|
||||
get() {
|
||||
return selectedFileFormats.value.filter((opt) =>
|
||||
availableFileFormats.value.some((a) => a.value === opt.value)
|
||||
)
|
||||
},
|
||||
set(value: SelectOption[]) {
|
||||
selectedFileFormats.value = value
|
||||
}
|
||||
})
|
||||
|
||||
const activeBaseModelObjects = computed({
|
||||
get() {
|
||||
return selectedBaseModels.value.filter((opt) =>
|
||||
availableBaseModels.value.some((a) => a.value === opt.value)
|
||||
)
|
||||
},
|
||||
set(value: SelectOption[]) {
|
||||
selectedBaseModels.value = value
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: AssetFilterState]
|
||||
}>()
|
||||
|
||||
function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
fileFormats: activeFileFormatObjects.value.map((opt) => opt.value),
|
||||
baseModels: activeBaseModelObjects.value.map((opt) => opt.value),
|
||||
sortBy: sortBy.value,
|
||||
ownership: ownership.value
|
||||
})
|
||||
|
||||
@@ -5,6 +5,12 @@ import { nextTick, ref } from 'vue'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
@@ -736,6 +742,90 @@ describe('useAssetBrowser', () => {
|
||||
expect(contentTitle.value).toBe('Assets')
|
||||
})
|
||||
|
||||
it('ignores stale file format filter when navigating to category without that format', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
id: 'ckpt-safetensors',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createApiAsset({
|
||||
id: 'lora-pt',
|
||||
name: 'lora.pt',
|
||||
tags: ['models', 'loras']
|
||||
}),
|
||||
createApiAsset({
|
||||
id: 'lora-pt-2',
|
||||
name: 'lora2.pt',
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
]
|
||||
|
||||
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||
useAssetBrowser(ref(assets))
|
||||
|
||||
// Select safetensors filter while viewing checkpoints
|
||||
selectedNavItem.value = 'checkpoints'
|
||||
updateFilters({
|
||||
sortBy: 'recent',
|
||||
fileFormats: ['safetensors'],
|
||||
baseModels: [],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].id).toBe('ckpt-safetensors')
|
||||
|
||||
// Navigate to loras category which has no .safetensors files
|
||||
selectedNavItem.value = 'loras'
|
||||
await nextTick()
|
||||
|
||||
// Should show all loras, not empty (stale filter should be ignored)
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores stale base model filter when navigating to category without that model', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
id: 'ckpt-sdxl',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { base_model: 'SDXL' }
|
||||
}),
|
||||
createApiAsset({
|
||||
id: 'lora-sd15',
|
||||
name: 'lora.pt',
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: { base_model: 'SD1.5' }
|
||||
})
|
||||
]
|
||||
|
||||
const { selectedNavItem, updateFilters, filteredAssets } =
|
||||
useAssetBrowser(ref(assets))
|
||||
|
||||
// Select SDXL base model filter while viewing checkpoints
|
||||
selectedNavItem.value = 'checkpoints'
|
||||
updateFilters({
|
||||
sortBy: 'recent',
|
||||
fileFormats: [],
|
||||
baseModels: ['SDXL'],
|
||||
ownership: 'all'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].id).toBe('ckpt-sdxl')
|
||||
|
||||
// Navigate to loras which has no SDXL models
|
||||
selectedNavItem.value = 'loras'
|
||||
await nextTick()
|
||||
|
||||
// Should show all loras, not empty
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].id).toBe('lora-sd15')
|
||||
})
|
||||
|
||||
it('groups models by top-level folder name', () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OwnershipOption
|
||||
} from '@/platform/assets/types/filterTypes'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import {
|
||||
filterByBaseModels,
|
||||
filterByCategory,
|
||||
@@ -192,6 +193,22 @@ export function useAssetBrowser(
|
||||
return assets.value.filter(filterByCategory(selectedCategory.value))
|
||||
})
|
||||
|
||||
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
||||
categoryFilteredAssets
|
||||
)
|
||||
|
||||
const activeFileFormats = computed(() =>
|
||||
filters.value.fileFormats.filter((f) =>
|
||||
availableFileFormats.value.some((opt) => opt.value === f)
|
||||
)
|
||||
)
|
||||
|
||||
const activeBaseModels = computed(() =>
|
||||
filters.value.baseModels.filter((m) =>
|
||||
availableBaseModels.value.some((opt) => opt.value === m)
|
||||
)
|
||||
)
|
||||
|
||||
const fuseOptions: UseFuseOptions<AssetItem> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
@@ -223,8 +240,8 @@ export function useAssetBrowser(
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = searchFiltered.value
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
.filter(filterByFileFormats(activeFileFormats.value))
|
||||
.filter(filterByBaseModels(activeBaseModels.value))
|
||||
.filter(filterByOwnership(selectedOwnership.value))
|
||||
|
||||
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
||||
|
||||
@@ -20,6 +20,8 @@ import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { isResultItemType } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { useAssetExportStore } from '@/stores/assetExportStore'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
@@ -73,7 +75,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', 1),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -87,16 +89,26 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple assets at once
|
||||
* @param assets Array of assets to download
|
||||
* Download multiple assets at once.
|
||||
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
|
||||
* Falls back to individual downloads in OSS mode or for single assets.
|
||||
*/
|
||||
const downloadMultipleAssets = (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
|
||||
const hasMultiOutputJobs = assets.some((a) => {
|
||||
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
|
||||
return typeof count === 'number' && count > 1
|
||||
})
|
||||
|
||||
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
|
||||
void downloadMultipleAssetsAsZip(assets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
@@ -104,9 +116,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', {
|
||||
count: assets.length
|
||||
}),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -120,6 +130,62 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
try {
|
||||
const jobIds: string[] = []
|
||||
const assetIds: string[] = []
|
||||
const jobAssetNameFilters: Record<string, string[]> = {}
|
||||
|
||||
for (const asset of assets) {
|
||||
if (getAssetType(asset) === 'output') {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const promptId = metadata?.promptId || asset.id
|
||||
if (!jobIds.includes(promptId)) {
|
||||
jobIds.push(promptId)
|
||||
}
|
||||
if (metadata?.promptId && asset.name) {
|
||||
if (!jobAssetNameFilters[metadata.promptId]) {
|
||||
jobAssetNameFilters[metadata.promptId] = []
|
||||
}
|
||||
if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) {
|
||||
jobAssetNameFilters[metadata.promptId].push(asset.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assetIds.push(asset.id)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await assetService.createAssetExport({
|
||||
...(jobIds.length > 0 ? { job_ids: jobIds } : {}),
|
||||
...(assetIds.length > 0 ? { asset_ids: assetIds } : {}),
|
||||
...(Object.keys(jobAssetNameFilters).length > 0
|
||||
? { job_asset_name_filters: jobAssetNameFilters }
|
||||
: {}),
|
||||
naming_strategy: 'preserve'
|
||||
})
|
||||
|
||||
assetExportStore.trackExport(result.task_id)
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('exportToast.exportStarted'),
|
||||
detail: t('mediaAsset.selection.exportStarted', assets.length),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create asset export:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('exportToast.exportFailedSingle'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const copyJobId = async (asset?: AssetItem) => {
|
||||
const targetAsset = asset ?? mediaContext?.asset.value
|
||||
if (!targetAsset) return
|
||||
@@ -580,9 +646,10 @@ export function useMediaAssetActions() {
|
||||
summary: t('g.success'),
|
||||
detail: isSingle
|
||||
? t('mediaAsset.assetDeletedSuccessfully')
|
||||
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
|
||||
count: succeeded
|
||||
}),
|
||||
: t(
|
||||
'mediaAsset.selection.assetsDeletedSuccessfully',
|
||||
succeeded
|
||||
),
|
||||
life: 2000
|
||||
})
|
||||
} else if (succeeded === 0) {
|
||||
|
||||
@@ -31,6 +31,17 @@ interface AssetRequestOptions extends PaginationOptions {
|
||||
includePublic?: boolean
|
||||
}
|
||||
|
||||
interface AssetExportOptions {
|
||||
job_ids?: string[]
|
||||
asset_ids?: string[]
|
||||
naming_strategy?:
|
||||
| 'group_by_job_id'
|
||||
| 'prepend_job_id'
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
job_asset_name_filters?: Record<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CivitAI validation error codes to localized error messages
|
||||
*/
|
||||
@@ -153,6 +164,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
|
||||
@@ -689,6 +701,34 @@ function createAssetService() {
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function createAssetExport(
|
||||
params: AssetExportOptions
|
||||
): Promise<{ task_id: string; status: string; message?: string }> {
|
||||
const res = await api.fetchApi(ASSETS_EXPORT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to create asset export: ${res.status}`)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
async function getExportDownloadUrl(
|
||||
exportName: string
|
||||
): Promise<{ url: string; expires_at?: string }> {
|
||||
const res = await api.fetchApi(`/assets/exports/${exportName}`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get export download URL: ${res.status}`)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -703,7 +743,9 @@ function createAssetService() {
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl,
|
||||
uploadAssetFromBase64,
|
||||
uploadAssetAsync
|
||||
uploadAssetAsync,
|
||||
createAssetExport,
|
||||
getExportDownloadUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
|
||||
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useSubscriptionDialog = () => {
|
||||
const component = useWorkspaceVariant
|
||||
? defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContentWorkspace.vue')
|
||||
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
|
||||
)
|
||||
: defineAsyncComponent(
|
||||
() =>
|
||||
|
||||
@@ -15,6 +15,18 @@ vi.mock('./nodeReplacementService', () => ({
|
||||
fetchNodeReplacements: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodeReplacementsEnabled = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: {
|
||||
get nodeReplacementsEnabled() {
|
||||
return mockNodeReplacementsEnabled.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
function mockSettingStore(enabled: boolean) {
|
||||
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
@@ -27,9 +39,10 @@ function mockSettingStore(enabled: boolean) {
|
||||
})
|
||||
}
|
||||
|
||||
function createStore(enabled = true) {
|
||||
function createStore(enabled = true, featureEnabled = true) {
|
||||
setActivePinia(createPinia())
|
||||
mockSettingStore(enabled)
|
||||
mockNodeReplacementsEnabled.value = featureEnabled
|
||||
return useNodeReplacementStore()
|
||||
}
|
||||
|
||||
@@ -38,6 +51,7 @@ describe('useNodeReplacementStore', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodeReplacementsEnabled.value = true
|
||||
store = createStore(true)
|
||||
})
|
||||
|
||||
@@ -257,5 +271,15 @@ describe('useNodeReplacementStore', () => {
|
||||
expect(fetchNodeReplacements).not.toHaveBeenCalled()
|
||||
expect(store.isLoaded).toBe(false)
|
||||
})
|
||||
|
||||
it('should not call API when server feature flag is disabled', async () => {
|
||||
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
|
||||
store = createStore(true, false)
|
||||
|
||||
await store.load()
|
||||
|
||||
expect(fetchNodeReplacements).not.toHaveBeenCalled()
|
||||
expect(store.isLoaded).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NodeReplacement, NodeReplacementResponse } from './types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { fetchNodeReplacements } from './nodeReplacementService'
|
||||
|
||||
@@ -14,8 +15,12 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
|
||||
settingStore.get('Comfy.NodeReplacement.Enabled')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
async function load() {
|
||||
if (!isEnabled.value || isLoaded.value) return
|
||||
if (!flags.nodeReplacementsEnabled) return
|
||||
|
||||
try {
|
||||
replacements.value = await fetchNodeReplacements()
|
||||
isLoaded.value = true
|
||||
|
||||
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal file
654
src/platform/nodeReplacement/useNodeReplacement.test.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeReplacement } from './types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
createNode: vi.fn(),
|
||||
registered_node_types: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: null },
|
||||
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { useNodeReplacement } from './useNodeReplacement'
|
||||
|
||||
function createMockLink(
|
||||
id: number,
|
||||
originId: number,
|
||||
originSlot: number,
|
||||
targetId: number,
|
||||
targetSlot: number
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
origin_id: originId,
|
||||
origin_slot: originSlot,
|
||||
target_id: targetId,
|
||||
target_slot: targetSlot,
|
||||
type: 'IMAGE'
|
||||
}
|
||||
}
|
||||
|
||||
function createMockGraph(
|
||||
nodes: LGraphNode[],
|
||||
links: ReturnType<typeof createMockLink>[] = []
|
||||
): LGraph {
|
||||
const linksMap = new Map(links.map((l) => [l.id, l]))
|
||||
return {
|
||||
_nodes: nodes,
|
||||
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
|
||||
links: linksMap,
|
||||
updateExecutionOrder: vi.fn(),
|
||||
setDirtyCanvas: vi.fn()
|
||||
} as unknown as LGraph
|
||||
}
|
||||
|
||||
function createPlaceholderNode(
|
||||
id: number,
|
||||
type: string,
|
||||
inputs: { name: string; link: number | null }[] = [],
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
graph?: LGraph
|
||||
): LGraphNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
pos: [100, 200],
|
||||
size: [200, 100],
|
||||
order: 0,
|
||||
mode: 0,
|
||||
flags: {},
|
||||
has_errors: true,
|
||||
last_serialization: {
|
||||
id,
|
||||
type,
|
||||
pos: [100, 200],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
widgets_values: []
|
||||
},
|
||||
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
graph: graph ?? null,
|
||||
serialize: vi.fn(() => ({
|
||||
id,
|
||||
type,
|
||||
pos: [100, 200],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
widgets_values: []
|
||||
}))
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createNewNode(
|
||||
inputs: { name: string; link: number | null }[] = [],
|
||||
outputs: { name: string; links: number[] | null }[] = [],
|
||||
widgets: { name: string; value: unknown }[] = []
|
||||
): LGraphNode {
|
||||
return {
|
||||
id: 0,
|
||||
type: '',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
order: 0,
|
||||
mode: 0,
|
||||
flags: {},
|
||||
has_errors: false,
|
||||
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
|
||||
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
|
||||
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
|
||||
configure: vi.fn(),
|
||||
serialize: vi.fn()
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeMissingNodeType(
|
||||
type: string,
|
||||
replacement: NodeReplacement
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
isReplaceable: true,
|
||||
replacement
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodeReplacement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('replaceNodesInPlace', () => {
|
||||
it('should return empty array when no placeholders exist', () => {
|
||||
const graph = createMockGraph([])
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
vi.mocked(collectAllNodes).mockReturnValue([])
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should use default mapping when no explicit mapping exists', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('Load3DAnimation', {
|
||||
new_node_id: 'Load3D',
|
||||
old_node_id: 'Load3DAnimation',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual(['Load3DAnimation'])
|
||||
expect(newNode.configure).not.toHaveBeenCalled()
|
||||
expect(newNode.id).toBe(1)
|
||||
expect(newNode.has_errors).toBe(false)
|
||||
})
|
||||
|
||||
it('should transfer input connections using input_mapping', () => {
|
||||
const link = createMockLink(10, 5, 0, 1, 0)
|
||||
const placeholder = createPlaceholderNode(
|
||||
1,
|
||||
'T2IAdapterLoader',
|
||||
[{ name: 't2i_adapter_name', link: 10 }],
|
||||
[]
|
||||
)
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'control_net_name', link: null }],
|
||||
[]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('T2IAdapterLoader', {
|
||||
new_node_id: 'ControlNetLoader',
|
||||
old_node_id: 'T2IAdapterLoader',
|
||||
old_widget_ids: null,
|
||||
input_mapping: [
|
||||
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual(['T2IAdapterLoader'])
|
||||
// Link should be updated to point at new node's input
|
||||
expect(link.target_id).toBe(1)
|
||||
expect(link.target_slot).toBe(0)
|
||||
expect(newNode.inputs[0].link).toBe(10)
|
||||
})
|
||||
|
||||
it('should transfer output connections using output_mapping', () => {
|
||||
const link = createMockLink(20, 1, 0, 5, 0)
|
||||
const placeholder = createPlaceholderNode(
|
||||
1,
|
||||
'ResizeImagesByLongerEdge',
|
||||
[],
|
||||
[{ name: 'IMAGE', links: [20] }]
|
||||
)
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'image', link: null }],
|
||||
[{ name: 'IMAGE', links: null }]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ResizeImagesByLongerEdge', {
|
||||
new_node_id: 'ImageScaleToMaxDimension',
|
||||
old_node_id: 'ResizeImagesByLongerEdge',
|
||||
old_widget_ids: ['longer_edge'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'images' },
|
||||
{ new_id: 'largest_size', old_id: 'longer_edge' },
|
||||
{ new_id: 'upscale_method', set_value: 'lanczos' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
})
|
||||
])
|
||||
|
||||
// Output link should be remapped
|
||||
expect(link.origin_id).toBe(1)
|
||||
expect(link.origin_slot).toBe(0)
|
||||
expect(newNode.outputs[0].links).toEqual([20])
|
||||
})
|
||||
|
||||
it('should apply set_value to widget', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'input', link: null }],
|
||||
[],
|
||||
[
|
||||
{ name: 'resize_type', value: '' },
|
||||
{ name: 'scale_method', value: '' }
|
||||
]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ImageScaleBy', {
|
||||
new_node_id: 'ResizeImageMaskNode',
|
||||
old_node_id: 'ImageScaleBy',
|
||||
old_widget_ids: ['upscale_method', 'scale_by'],
|
||||
input_mapping: [
|
||||
{ new_id: 'input', old_id: 'image' },
|
||||
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
|
||||
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
|
||||
{ new_id: 'scale_method', old_id: 'upscale_method' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
// set_value should be applied to the widget
|
||||
expect(newNode.widgets![0].value).toBe('scale by multiplier')
|
||||
})
|
||||
|
||||
it('should transfer widget values using old_widget_ids', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
|
||||
// Set widget values in serialized data
|
||||
placeholder.last_serialization!.widgets_values = [512]
|
||||
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[
|
||||
{ name: 'image', link: null },
|
||||
{ name: 'largest_size', link: null }
|
||||
],
|
||||
[{ name: 'IMAGE', links: null }],
|
||||
[{ name: 'largest_size', value: 0 }]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ResizeImagesByLongerEdge', {
|
||||
new_node_id: 'ImageScaleToMaxDimension',
|
||||
old_node_id: 'ResizeImagesByLongerEdge',
|
||||
old_widget_ids: ['longer_edge'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'images' },
|
||||
{ new_id: 'largest_size', old_id: 'longer_edge' },
|
||||
{ new_id: 'upscale_method', set_value: 'lanczos' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
})
|
||||
])
|
||||
|
||||
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
|
||||
expect(newNode.widgets![0].value).toBe(512)
|
||||
})
|
||||
|
||||
it('should skip replacement when new node type is not registered', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'UnknownNode')
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('UnknownNode', {
|
||||
new_node_id: 'NonExistentNode',
|
||||
old_node_id: 'UnknownNode',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should replace multiple different node types at once', () => {
|
||||
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
|
||||
const placeholder2 = createPlaceholderNode(
|
||||
2,
|
||||
'ConditioningAverage',
|
||||
[],
|
||||
[]
|
||||
)
|
||||
// sanitizeNodeName strips & from type names (HTML entity chars)
|
||||
placeholder2.type = 'ConditioningAverage'
|
||||
|
||||
const graph = createMockGraph([placeholder1, placeholder2])
|
||||
placeholder1.graph = graph
|
||||
placeholder2.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
|
||||
|
||||
const newNode1 = createNewNode()
|
||||
const newNode2 = createNewNode()
|
||||
vi.mocked(LiteGraph.createNode)
|
||||
.mockReturnValueOnce(newNode1)
|
||||
.mockReturnValueOnce(newNode2)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('Load3DAnimation', {
|
||||
new_node_id: 'Load3D',
|
||||
old_node_id: 'Load3DAnimation',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}),
|
||||
makeMissingNodeType('ConditioningAverage&', {
|
||||
new_node_id: 'ConditioningAverage',
|
||||
old_node_id: 'ConditioningAverage&',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toContain('Load3DAnimation')
|
||||
expect(result).toContain('ConditioningAverage&')
|
||||
})
|
||||
|
||||
it('should copy position and identity for mapped replacements', () => {
|
||||
const link = createMockLink(10, 5, 0, 1, 0)
|
||||
const placeholder = createPlaceholderNode(
|
||||
42,
|
||||
'T2IAdapterLoader',
|
||||
[{ name: 't2i_adapter_name', link: 10 }],
|
||||
[]
|
||||
)
|
||||
placeholder.pos = [300, 400]
|
||||
placeholder.size = [250, 150]
|
||||
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'control_net_name', link: null }],
|
||||
[]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('T2IAdapterLoader', {
|
||||
new_node_id: 'ControlNetLoader',
|
||||
old_node_id: 'T2IAdapterLoader',
|
||||
old_widget_ids: null,
|
||||
input_mapping: [
|
||||
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
expect(newNode.id).toBe(42)
|
||||
expect(newNode.pos).toEqual([300, 400])
|
||||
expect(newNode.size).toEqual([250, 150])
|
||||
expect(graph._nodes[0]).toBe(newNode)
|
||||
})
|
||||
|
||||
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
|
||||
const placeholder = createPlaceholderNode(
|
||||
12,
|
||||
'ImageScaleBy',
|
||||
[{ name: 'image', link: 2 }],
|
||||
[{ name: 'IMAGE', links: [3, 4] }]
|
||||
)
|
||||
// Real workflow data: widgets_values: ["lanczos", 2.0]
|
||||
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
|
||||
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[{ name: 'input', link: null }],
|
||||
[],
|
||||
[
|
||||
{ name: 'resize_type', value: '' },
|
||||
{ name: 'scale_method', value: '' }
|
||||
]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ImageScaleBy', {
|
||||
new_node_id: 'ResizeImageMaskNode',
|
||||
old_node_id: 'ImageScaleBy',
|
||||
old_widget_ids: ['upscale_method', 'scale_by'],
|
||||
input_mapping: [
|
||||
{ new_id: 'input', old_id: 'image' },
|
||||
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
|
||||
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
|
||||
{ new_id: 'scale_method', old_id: 'upscale_method' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
// set_value should be applied
|
||||
expect(newNode.widgets![0].value).toBe('scale by multiplier')
|
||||
// upscale_method (idx 0, value "lanczos") → scale_method widget
|
||||
expect(newNode.widgets![1].value).toBe('lanczos')
|
||||
})
|
||||
|
||||
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
|
||||
const link = createMockLink(1, 5, 0, 8, 0)
|
||||
const placeholder = createPlaceholderNode(
|
||||
8,
|
||||
'ResizeImagesByLongerEdge',
|
||||
[{ name: 'images', link: 1 }],
|
||||
[{ name: 'IMAGE', links: [2] }]
|
||||
)
|
||||
// Real workflow data: widgets_values: [1024]
|
||||
placeholder.last_serialization!.widgets_values = [1024]
|
||||
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[
|
||||
{ name: 'image', link: null },
|
||||
{ name: 'largest_size', link: null }
|
||||
],
|
||||
[{ name: 'IMAGE', links: null }],
|
||||
[
|
||||
{ name: 'largest_size', value: 0 },
|
||||
{ name: 'upscale_method', value: '' }
|
||||
]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ResizeImagesByLongerEdge', {
|
||||
new_node_id: 'ImageScaleToMaxDimension',
|
||||
old_node_id: 'ResizeImagesByLongerEdge',
|
||||
old_widget_ids: ['longer_edge'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'images' },
|
||||
{ new_id: 'largest_size', old_id: 'longer_edge' },
|
||||
{ new_id: 'upscale_method', set_value: 'lanczos' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
})
|
||||
])
|
||||
|
||||
// longer_edge (idx 0, value 1024) → largest_size widget
|
||||
expect(newNode.widgets![0].value).toBe(1024)
|
||||
// set_value "lanczos" → upscale_method widget
|
||||
expect(newNode.widgets![1].value).toBe('lanczos')
|
||||
})
|
||||
|
||||
it('should transfer ConditioningAverage widget value with real workflow data', () => {
|
||||
const link = createMockLink(4, 7, 0, 13, 0)
|
||||
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
|
||||
const placeholder = createPlaceholderNode(
|
||||
13,
|
||||
'ConditioningAverage ',
|
||||
[
|
||||
{ name: 'conditioning_to', link: 4 },
|
||||
{ name: 'conditioning_from', link: null }
|
||||
],
|
||||
[{ name: 'CONDITIONING', links: [6] }]
|
||||
)
|
||||
placeholder.last_serialization!.widgets_values = [0.75]
|
||||
|
||||
const graph = createMockGraph([placeholder], [link])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode(
|
||||
[
|
||||
{ name: 'conditioning_to', link: null },
|
||||
{ name: 'conditioning_from', link: null }
|
||||
],
|
||||
[{ name: 'CONDITIONING', links: null }],
|
||||
[{ name: 'conditioning_average', value: 0 }]
|
||||
)
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
replaceNodesInPlace([
|
||||
makeMissingNodeType('ConditioningAverage ', {
|
||||
new_node_id: 'ConditioningAverage',
|
||||
old_node_id: 'ConditioningAverage ',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
// Default mapping transfers connections and widget values by name
|
||||
expect(newNode.id).toBe(13)
|
||||
expect(newNode.inputs[0].link).toBe(4)
|
||||
expect(newNode.outputs[0].links).toEqual([6])
|
||||
expect(newNode.widgets![0].value).toBe(0.75)
|
||||
})
|
||||
|
||||
it('should skip dot-notation input connections but still transfer widget values', () => {
|
||||
const placeholder = createPlaceholderNode(1, 'ImageBatch')
|
||||
const graph = createMockGraph([placeholder])
|
||||
placeholder.graph = graph
|
||||
Object.assign(app, { rootGraph: graph })
|
||||
|
||||
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
|
||||
|
||||
const newNode = createNewNode([], [])
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const result = replaceNodesInPlace([
|
||||
makeMissingNodeType('ImageBatch', {
|
||||
new_node_id: 'BatchImagesNode',
|
||||
old_node_id: 'ImageBatch',
|
||||
old_widget_ids: null,
|
||||
input_mapping: [
|
||||
{ new_id: 'images.image0', old_id: 'image1' },
|
||||
{ new_id: 'images.image1', old_id: 'image2' }
|
||||
],
|
||||
output_mapping: null
|
||||
})
|
||||
])
|
||||
|
||||
// Should still succeed (dot-notation skipped gracefully)
|
||||
expect(result).toEqual(['ImageBatch'])
|
||||
})
|
||||
})
|
||||
})
|
||||
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal file
292
src/platform/nodeReplacement/useNodeReplacement.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { t } from '@/i18n'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app, sanitizeNodeName } from '@/scripts/app'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/** Compares sanitized type strings to match placeholder → missing node type. */
|
||||
function findMatchingType(
|
||||
node: LGraphNode,
|
||||
selectedTypes: MissingNodeType[]
|
||||
): Extract<MissingNodeType, { type: string }> | undefined {
|
||||
const nodeType = node.type
|
||||
for (const selected of selectedTypes) {
|
||||
if (typeof selected !== 'object' || !selected.isReplaceable) continue
|
||||
if (sanitizeNodeName(selected.type) === nodeType) return selected
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function transferInputConnection(
|
||||
oldNode: LGraphNode,
|
||||
oldInputName: string,
|
||||
newNode: LGraphNode,
|
||||
newInputName: string,
|
||||
graph: LGraph
|
||||
): void {
|
||||
const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName)
|
||||
const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName)
|
||||
if (oldSlotIdx == null || oldSlotIdx === -1) return
|
||||
if (newSlotIdx == null || newSlotIdx === -1) return
|
||||
|
||||
const linkId = oldNode.inputs[oldSlotIdx].link
|
||||
if (linkId == null) return
|
||||
|
||||
const link = graph.links.get(linkId)
|
||||
if (!link) return
|
||||
|
||||
link.target_id = newNode.id
|
||||
link.target_slot = newSlotIdx
|
||||
newNode.inputs[newSlotIdx].link = linkId
|
||||
oldNode.inputs[oldSlotIdx].link = null
|
||||
}
|
||||
|
||||
function transferOutputConnections(
|
||||
oldNode: LGraphNode,
|
||||
oldOutputIdx: number,
|
||||
newNode: LGraphNode,
|
||||
newOutputIdx: number,
|
||||
graph: LGraph
|
||||
): void {
|
||||
const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links
|
||||
if (!oldLinks?.length) return
|
||||
if (!newNode.outputs?.[newOutputIdx]) return
|
||||
|
||||
for (const linkId of oldLinks) {
|
||||
const link = graph.links.get(linkId)
|
||||
if (!link) continue
|
||||
link.origin_id = newNode.id
|
||||
link.origin_slot = newOutputIdx
|
||||
}
|
||||
newNode.outputs[newOutputIdx].links = [...oldLinks]
|
||||
oldNode.outputs[oldOutputIdx].links = []
|
||||
}
|
||||
|
||||
/** Uses old_widget_ids as name→index lookup into widgets_values. */
|
||||
function transferWidgetValue(
|
||||
serialized: ISerialisedNode,
|
||||
oldWidgetIds: string[] | null,
|
||||
oldInputName: string,
|
||||
newNode: LGraphNode,
|
||||
newInputName: string
|
||||
): void {
|
||||
if (!oldWidgetIds || !serialized.widgets_values) return
|
||||
|
||||
const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName)
|
||||
if (oldWidgetIdx === -1) return
|
||||
|
||||
const oldValue = serialized.widgets_values[oldWidgetIdx]
|
||||
if (oldValue === undefined) return
|
||||
|
||||
const newWidget = newNode.widgets?.find((w) => w.name === newInputName)
|
||||
if (newWidget) {
|
||||
newWidget.value = oldValue
|
||||
newWidget.callback?.(oldValue)
|
||||
}
|
||||
}
|
||||
|
||||
function applySetValue(
|
||||
newNode: LGraphNode,
|
||||
inputName: string,
|
||||
value: unknown
|
||||
): void {
|
||||
const widget = newNode.widgets?.find((w) => w.name === inputName)
|
||||
if (widget) {
|
||||
widget.value = value as TWidgetValue
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isDotNotation(id: string): boolean {
|
||||
return id.includes('.')
|
||||
}
|
||||
|
||||
/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */
|
||||
function generateDefaultMapping(
|
||||
serialized: ISerialisedNode,
|
||||
newNode: LGraphNode
|
||||
): Pick<
|
||||
NodeReplacement,
|
||||
'input_mapping' | 'output_mapping' | 'old_widget_ids'
|
||||
> {
|
||||
const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? [])
|
||||
|
||||
const inputMapping: { old_id: string; new_id: string }[] = []
|
||||
for (const newInput of newNode.inputs ?? []) {
|
||||
if (oldInputNames.has(newInput.name)) {
|
||||
inputMapping.push({ old_id: newInput.name, new_id: newInput.name })
|
||||
}
|
||||
}
|
||||
|
||||
const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name)
|
||||
for (const widget of newNode.widgets ?? []) {
|
||||
if (!oldInputNames.has(widget.name)) {
|
||||
inputMapping.push({ old_id: widget.name, new_id: widget.name })
|
||||
}
|
||||
}
|
||||
|
||||
const outputMapping: { old_idx: number; new_idx: number }[] = []
|
||||
for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) {
|
||||
const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name)
|
||||
if (newIdx != null && newIdx !== -1) {
|
||||
outputMapping.push({ old_idx: oldIdx, new_idx: newIdx })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input_mapping: inputMapping.length > 0 ? inputMapping : null,
|
||||
output_mapping: outputMapping.length > 0 ? outputMapping : null,
|
||||
old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null
|
||||
}
|
||||
}
|
||||
|
||||
function replaceWithMapping(
|
||||
node: LGraphNode,
|
||||
newNode: LGraphNode,
|
||||
replacement: NodeReplacement,
|
||||
nodeGraph: LGraph,
|
||||
idx: number
|
||||
): void {
|
||||
newNode.id = node.id
|
||||
newNode.pos = [...node.pos]
|
||||
newNode.size = [...node.size]
|
||||
newNode.order = node.order
|
||||
newNode.mode = node.mode
|
||||
if (node.flags) newNode.flags = { ...node.flags }
|
||||
|
||||
nodeGraph._nodes[idx] = newNode
|
||||
newNode.graph = nodeGraph
|
||||
nodeGraph._nodes_by_id[newNode.id] = newNode
|
||||
|
||||
const serialized = node.last_serialization ?? node.serialize()
|
||||
|
||||
if (serialized.title != null) newNode.title = serialized.title
|
||||
if (serialized.properties) {
|
||||
newNode.properties = { ...serialized.properties }
|
||||
if ('Node name for S&R' in newNode.properties) {
|
||||
newNode.properties['Node name for S&R'] = replacement.new_node_id
|
||||
}
|
||||
}
|
||||
|
||||
if (replacement.input_mapping) {
|
||||
for (const inputMap of replacement.input_mapping) {
|
||||
if ('old_id' in inputMap) {
|
||||
if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo
|
||||
transferInputConnection(
|
||||
node,
|
||||
inputMap.old_id,
|
||||
newNode,
|
||||
inputMap.new_id,
|
||||
nodeGraph
|
||||
)
|
||||
transferWidgetValue(
|
||||
serialized,
|
||||
replacement.old_widget_ids,
|
||||
inputMap.old_id,
|
||||
newNode,
|
||||
inputMap.new_id
|
||||
)
|
||||
} else {
|
||||
if (!isDotNotation(inputMap.new_id)) {
|
||||
applySetValue(newNode, inputMap.new_id, inputMap.set_value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacement.output_mapping) {
|
||||
for (const outMap of replacement.output_mapping) {
|
||||
transferOutputConnections(
|
||||
node,
|
||||
outMap.old_idx,
|
||||
newNode,
|
||||
outMap.new_idx,
|
||||
nodeGraph
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
newNode.has_errors = false
|
||||
}
|
||||
|
||||
export function useNodeReplacement() {
|
||||
const toastStore = useToastStore()
|
||||
|
||||
function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] {
|
||||
const replacedTypes: string[] = []
|
||||
const graph = app.rootGraph
|
||||
|
||||
const changeTracker =
|
||||
useWorkflowStore().activeWorkflow?.changeTracker ?? null
|
||||
changeTracker?.beforeChange()
|
||||
|
||||
try {
|
||||
const placeholders = collectAllNodes(
|
||||
graph,
|
||||
(n) => !!n.has_errors && !!n.last_serialization
|
||||
)
|
||||
|
||||
for (const node of placeholders) {
|
||||
const match = findMatchingType(node, selectedTypes)
|
||||
if (!match?.replacement) continue
|
||||
|
||||
const replacement = match.replacement
|
||||
const nodeGraph = node.graph
|
||||
if (!nodeGraph) continue
|
||||
|
||||
const idx = nodeGraph._nodes.indexOf(node)
|
||||
if (idx === -1) continue
|
||||
|
||||
const newNode = LiteGraph.createNode(replacement.new_node_id)
|
||||
if (!newNode) continue
|
||||
|
||||
const hasMapping =
|
||||
replacement.input_mapping != null ||
|
||||
replacement.output_mapping != null
|
||||
|
||||
const effectiveReplacement = hasMapping
|
||||
? replacement
|
||||
: {
|
||||
...replacement,
|
||||
...generateDefaultMapping(
|
||||
node.last_serialization ?? node.serialize(),
|
||||
newNode
|
||||
)
|
||||
}
|
||||
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
|
||||
|
||||
if (!replacedTypes.includes(match.type)) {
|
||||
replacedTypes.push(match.type)
|
||||
}
|
||||
}
|
||||
|
||||
if (replacedTypes.length > 0) {
|
||||
graph.updateExecutionOrder()
|
||||
graph.setDirtyCanvas(true, true)
|
||||
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('nodeReplacement.replacedAllNodes', {
|
||||
count: replacedTypes.length
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
changeTracker?.afterChange()
|
||||
}
|
||||
|
||||
return replacedTypes
|
||||
}
|
||||
|
||||
return {
|
||||
replaceNodesInPlace
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = {
|
||||
*/
|
||||
export type RemoteConfig = {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
|
||||
@@ -177,7 +177,7 @@ export function useSettingUI(
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/components/dialog/content/setting/WorkspacePanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1202,9 +1202,9 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'Comfy.NodeReplacement.Enabled',
|
||||
category: ['Comfy', 'Workflow', 'NodeReplacement'],
|
||||
name: 'Enable automatic node replacement',
|
||||
name: 'Enable node replacement suggestions',
|
||||
tooltip:
|
||||
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
|
||||
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
@@ -1221,5 +1221,16 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
category: ['Comfy', 'Error System'],
|
||||
name: 'Show errors tab in side panel',
|
||||
tooltip:
|
||||
'When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
describe('GtmTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.__CONFIG__ = {}
|
||||
window.dataLayer = undefined
|
||||
window.gtag = undefined
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('injects the GTM runtime script', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtmScript = document.querySelector(
|
||||
'script[src="https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"]'
|
||||
)
|
||||
|
||||
expect(gtmScript).not.toBeNull()
|
||||
expect(window.dataLayer?.[0]).toMatchObject({
|
||||
event: 'gtm.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('bootstraps gtag when a GA measurement id exists', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtagScript = document.querySelector(
|
||||
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
|
||||
)
|
||||
const dataLayer = window.dataLayer as unknown[]
|
||||
|
||||
expect(gtagScript).not.toBeNull()
|
||||
expect(typeof window.gtag).toBe('function')
|
||||
expect(dataLayer).toHaveLength(2)
|
||||
expect(Array.from(dataLayer[0] as IArguments)[0]).toBe('js')
|
||||
expect(Array.from(dataLayer[1] as IArguments)).toEqual([
|
||||
'config',
|
||||
'G-TEST123',
|
||||
{
|
||||
send_page_view: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not inject duplicate gtag scripts across repeated init', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtagScripts = document.querySelectorAll(
|
||||
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
|
||||
)
|
||||
|
||||
expect(gtagScripts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -22,13 +22,21 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const gtmId = window.__CONFIG__?.gtm_container_id
|
||||
if (!gtmId) {
|
||||
if (gtmId) {
|
||||
this.initializeGtm(gtmId)
|
||||
} else {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.warn('[GTM] No GTM ID configured, skipping initialization')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const measurementId = window.__CONFIG__?.ga_measurement_id
|
||||
if (measurementId) {
|
||||
this.bootstrapGtag(measurementId)
|
||||
}
|
||||
}
|
||||
|
||||
private initializeGtm(gtmId: string): void {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.dataLayer.push({
|
||||
@@ -44,6 +52,38 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private bootstrapGtag(measurementId: string): void {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
if (typeof window.gtag !== 'function') {
|
||||
function gtag() {
|
||||
// gtag queue shape is dataLayer.push(arguments)
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
;(window.dataLayer as unknown[] | undefined)?.push(arguments)
|
||||
}
|
||||
|
||||
window.gtag = gtag as Window['gtag']
|
||||
}
|
||||
|
||||
const gtagScriptSrc = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`
|
||||
const existingGtagScript = document.querySelector(
|
||||
`script[src="${gtagScriptSrc}"]`
|
||||
)
|
||||
|
||||
if (!existingGtagScript) {
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = gtagScriptSrc
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
}
|
||||
|
||||
const gtag = window.gtag
|
||||
if (typeof gtag !== 'function') return
|
||||
|
||||
gtag('js', new Date())
|
||||
gtag('config', measurementId, { send_page_view: false })
|
||||
}
|
||||
|
||||
private pushEvent(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!this.initialized) return
|
||||
window.dataLayer?.push({ event, ...properties })
|
||||
|
||||
@@ -9,17 +9,37 @@ describe('getCheckoutAttribution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
window.__ga_identity__ = undefined
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: undefined
|
||||
}
|
||||
window.gtag = undefined
|
||||
window.ire = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
|
||||
window.__ga_identity__ = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
const gtagSpy = vi.fn(
|
||||
(
|
||||
_command: 'get',
|
||||
_targetId: string,
|
||||
fieldName: GtagGetFieldName,
|
||||
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
|
||||
) => {
|
||||
const valueByField = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
}
|
||||
callback(valueByField[fieldName])
|
||||
}
|
||||
)
|
||||
window.gtag = gtagSpy as unknown as Window['gtag']
|
||||
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
@@ -48,6 +68,61 @@ describe('getCheckoutAttribution', () => {
|
||||
'generateClickId',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'client_id',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_id',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_number',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('stringifies numeric GA values from gtag', async () => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
const gtagSpy = vi.fn(
|
||||
(
|
||||
_command: 'get',
|
||||
_targetId: string,
|
||||
fieldName: GtagGetFieldName,
|
||||
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
|
||||
) => {
|
||||
const valueByField = {
|
||||
client_id: '123.456',
|
||||
session_id: 1700000000,
|
||||
session_number: 2
|
||||
}
|
||||
callback(valueByField[fieldName])
|
||||
}
|
||||
)
|
||||
window.gtag = gtagSpy as unknown as Window['gtag']
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
ga_client_id: '123.456',
|
||||
ga_session_id: '1700000000',
|
||||
ga_session_number: '2'
|
||||
})
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_number',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to URL click id when generateClickId is unavailable', async () => {
|
||||
|
||||
@@ -9,6 +9,13 @@ type GaIdentity = {
|
||||
session_number?: string
|
||||
}
|
||||
|
||||
const GA_IDENTITY_FIELDS = [
|
||||
'client_id',
|
||||
'session_id',
|
||||
'session_number'
|
||||
] as const satisfies ReadonlyArray<GtagGetFieldName>
|
||||
type GaIdentityField = GtagGetFieldName
|
||||
|
||||
const ATTRIBUTION_QUERY_KEYS = [
|
||||
'im_ref',
|
||||
'utm_source',
|
||||
@@ -23,6 +30,7 @@ const ATTRIBUTION_QUERY_KEYS = [
|
||||
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
|
||||
const GET_GA_IDENTITY_TIMEOUT_MS = 300
|
||||
|
||||
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
@@ -93,19 +101,53 @@ function hasAttributionChanges(
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getGaIdentity(): GaIdentity | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
async function getGaIdentityField(
|
||||
measurementId: string,
|
||||
fieldName: GaIdentityField
|
||||
): Promise<string | undefined> {
|
||||
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
|
||||
return undefined
|
||||
}
|
||||
const gtag = window.gtag
|
||||
|
||||
const identity = window.__ga_identity__
|
||||
if (!isPlainObject(identity)) return undefined
|
||||
return withTimeout(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
gtag('get', measurementId, fieldName, (value) => {
|
||||
resolve(asNonEmptyString(value))
|
||||
})
|
||||
}),
|
||||
GET_GA_IDENTITY_TIMEOUT_MS
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function getGaIdentity(): Promise<GaIdentity | undefined> {
|
||||
const measurementId = asNonEmptyString(window.__CONFIG__?.ga_measurement_id)
|
||||
if (!measurementId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [clientId, sessionId, sessionNumber] = await Promise.all(
|
||||
GA_IDENTITY_FIELDS.map((fieldName) =>
|
||||
getGaIdentityField(measurementId, fieldName)
|
||||
)
|
||||
)
|
||||
|
||||
if (!clientId && !sessionId && !sessionNumber) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
client_id: asNonEmptyString(identity.client_id),
|
||||
session_id: asNonEmptyString(identity.session_id),
|
||||
session_number: asNonEmptyString(identity.session_number)
|
||||
client_id: clientId,
|
||||
session_id: sessionId,
|
||||
session_number: sessionNumber
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +212,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const gaIdentity = getGaIdentity()
|
||||
const gaIdentity = await getGaIdentity()
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
|
||||
251
src/platform/workflow/core/services/workflowService.test.ts
Normal file
251
src/platform/workflow/core/services/workflowService.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { mockShowLoadWorkflowWarning, mockShowMissingModelsWarning } =
|
||||
vi.hoisted(() => ({
|
||||
mockShowLoadWorkflowWarning: vi.fn(),
|
||||
mockShowMissingModelsWarning: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLoadWorkflowWarning: mockShowLoadWorkflowWarning,
|
||||
showMissingModelsWarning: mockShowMissingModelsWarning,
|
||||
prompt: vi.fn(),
|
||||
confirm: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { ds: { offset: [0, 0], scale: 1 } },
|
||||
rootGraph: { serialize: vi.fn(() => ({})) },
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/defaultGraph', () => ({
|
||||
defaultGraph: {},
|
||||
blankGraph: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
storeThumbnail: vi.fn(),
|
||||
getThumbnail: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
|
||||
useWorkflowDraftStore: () => ({
|
||||
saveDraft: vi.fn(),
|
||||
getDraft: vi.fn(),
|
||||
removeDraft: vi.fn(),
|
||||
markDraftUsed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
clear: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const MISSING_MODELS: PendingWarnings['missingModels'] = {
|
||||
missingModels: [
|
||||
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
|
||||
],
|
||||
paths: { checkpoints: ['/models/checkpoints'] }
|
||||
}
|
||||
|
||||
function createWorkflow(
|
||||
warnings: PendingWarnings | null = null,
|
||||
options: { loadable?: boolean; path?: string } = {}
|
||||
): ComfyWorkflow {
|
||||
return {
|
||||
pendingWarnings: warnings,
|
||||
...(options.loadable && {
|
||||
path: options.path ?? 'workflows/test.json',
|
||||
isLoaded: true,
|
||||
activeState: { nodes: [], links: [] },
|
||||
changeTracker: { reset: vi.fn(), restore: vi.fn() }
|
||||
})
|
||||
} as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function enableWarningSettings() {
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
(key: string): boolean => {
|
||||
if (key === 'Comfy.Workflow.ShowMissingNodesWarning') return true
|
||||
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
|
||||
return false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('useWorkflowService', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('showPendingWarnings', () => {
|
||||
beforeEach(() => {
|
||||
enableWarningSettings()
|
||||
})
|
||||
|
||||
it('should do nothing when workflow has no pending warnings', () => {
|
||||
const workflow = createWorkflow(null)
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show missing nodes dialog and clear warnings', () => {
|
||||
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
|
||||
const workflow = createWorkflow({ missingNodeTypes })
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes
|
||||
})
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should show missing models dialog and clear warnings', () => {
|
||||
const workflow = createWorkflow({ missingModels: MISSING_MODELS })
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowMissingModelsWarning).toHaveBeenCalledWith(MISSING_MODELS)
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should not show dialogs when settings are disabled', () => {
|
||||
vi.spyOn(useSettingStore(), 'get').mockReturnValue(false)
|
||||
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1'],
|
||||
missingModels: MISSING_MODELS
|
||||
})
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should only show warnings once across multiple calls', () => {
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
|
||||
const service = useWorkflowService()
|
||||
service.showPendingWarnings(workflow)
|
||||
service.showPendingWarnings(workflow)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWorkflow deferred warnings', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
enableWarningSettings()
|
||||
workflowStore = useWorkflowStore()
|
||||
vi.mocked(app.loadGraphData).mockImplementation(
|
||||
async (_data, _clean, _restore, wf) => {
|
||||
;(
|
||||
workflowStore as unknown as Record<string, unknown>
|
||||
).activeWorkflow = wf
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should defer warnings during load and show on focus', async () => {
|
||||
const workflow = createWorkflow(
|
||||
{ missingNodeTypes: ['CustomNode1'] },
|
||||
{ loadable: true }
|
||||
)
|
||||
|
||||
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
|
||||
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
true,
|
||||
true,
|
||||
workflow,
|
||||
expect.objectContaining({ deferWarnings: true })
|
||||
)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should show each workflow warnings only when that tab is focused', async () => {
|
||||
const workflow1 = createWorkflow(
|
||||
{ missingNodeTypes: ['MissingNodeA'] },
|
||||
{ loadable: true, path: 'workflows/first.json' }
|
||||
)
|
||||
const workflow2 = createWorkflow(
|
||||
{ missingNodeTypes: ['MissingNodeB'] },
|
||||
{ loadable: true, path: 'workflows/second.json' }
|
||||
)
|
||||
|
||||
const service = useWorkflowService()
|
||||
|
||||
await service.openWorkflow(workflow1)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
|
||||
missingNodeTypes: ['MissingNodeA']
|
||||
})
|
||||
expect(workflow1.pendingWarnings).toBeNull()
|
||||
expect(workflow2.pendingWarnings).not.toBeNull()
|
||||
|
||||
await service.openWorkflow(workflow2)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(2)
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenLastCalledWith({
|
||||
missingNodeTypes: ['MissingNodeB']
|
||||
})
|
||||
expect(workflow2.pendingWarnings).toBeNull()
|
||||
})
|
||||
|
||||
it('should not show warnings when refocusing a cleared tab', async () => {
|
||||
const workflow = createWorkflow(
|
||||
{ missingNodeTypes: ['CustomNode1'] },
|
||||
{ loadable: true }
|
||||
)
|
||||
|
||||
const service = useWorkflowService()
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -183,9 +183,11 @@ export const useWorkflowService = () => {
|
||||
{
|
||||
showMissingModelsDialog: loadFromRemote,
|
||||
showMissingNodesDialog: loadFromRemote,
|
||||
checkForRerouteMigration: false
|
||||
checkForRerouteMigration: false,
|
||||
deferWarnings: true
|
||||
}
|
||||
)
|
||||
showPendingWarnings()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,6 +439,32 @@ export const useWorkflowService = () => {
|
||||
await app.loadGraphData(state, true, true, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show and clear any pending warnings (missing nodes/models) stored on the
|
||||
* active workflow. Called after a workflow becomes visible so dialogs don't
|
||||
* overlap with subsequent loads.
|
||||
*/
|
||||
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
|
||||
const wf = workflow ?? workflowStore.activeWorkflow
|
||||
if (!wf?.pendingWarnings) return
|
||||
|
||||
const { missingNodeTypes, missingModels } = wf.pendingWarnings
|
||||
wf.pendingWarnings = null
|
||||
|
||||
if (
|
||||
missingNodeTypes?.length &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
|
||||
) {
|
||||
void dialogService.showLoadWorkflowWarning({ missingNodeTypes })
|
||||
}
|
||||
if (
|
||||
missingModels &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
void dialogService.showMissingModelsWarning(missingModels)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportWorkflow,
|
||||
saveWorkflowAs,
|
||||
@@ -452,6 +480,7 @@ export const useWorkflowService = () => {
|
||||
loadNextOpenedWorkflow,
|
||||
loadPreviousOpenedWorkflow,
|
||||
duplicateWorkflow,
|
||||
showPendingWarnings,
|
||||
afterLoadNewGraph,
|
||||
beforeLoadNewGraph
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user