Compare commits
14 Commits
fix/node-s
...
origin/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c0e331eb | ||
|
|
b47414a52f | ||
|
|
631d484901 | ||
|
|
e83e396c09 | ||
|
|
821c1e74ff | ||
|
|
d06cc0819a | ||
|
|
f5f5a77435 | ||
|
|
efe78b799f | ||
|
|
e70484d596 | ||
|
|
3dba245dd3 | ||
|
|
2ca0c30cf7 | ||
|
|
c8ba5f7300 | ||
|
|
39cc8ab97a | ||
|
|
2ee0a1337c |
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
@@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
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'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
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.6",
|
||||
"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 () => {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
@@ -68,6 +68,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 +145,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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -814,6 +814,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)",
|
||||
@@ -2900,6 +2903,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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -249,8 +249,8 @@
|
||||
"name": "API 노드 가격 배지 표시"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "자동 노드 교체 활성화",
|
||||
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
|
||||
"name": "노드 교체 제안 활성화",
|
||||
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "노드 검색 상자 구현",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,19 @@ import { markRaw } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
ModelFile
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export interface PendingWarnings {
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
missingModels?: {
|
||||
missingModels: ModelFile[]
|
||||
paths: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
@@ -17,6 +29,10 @@ export class ComfyWorkflow extends UserFile {
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
/**
|
||||
* Warnings deferred from load time, shown when the workflow is first focused.
|
||||
*/
|
||||
pendingWarnings: PendingWarnings | null = null
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
|
||||
@@ -207,8 +207,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -357,7 +357,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -74,7 +74,7 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricin
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
@@ -162,7 +162,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -112,9 +112,9 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
WorkspaceRole,
|
||||
@@ -120,12 +120,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -29,7 +29,7 @@ import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
} from '../../../composables/billing/types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
|
||||
@@ -25,7 +25,7 @@ const mockWorkspaceAuthStore = vi.hoisted(() => ({
|
||||
clearWorkspaceContext: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceAuthStore', () => ({
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
|
||||
import type {
|
||||
ListMembersParams,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
useWorkspaceAuthStore,
|
||||
WorkspaceAuthError
|
||||
} from '@/stores/workspaceAuthStore'
|
||||
} from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
|
||||
import { WORKSPACE_STORAGE_KEYS } from './workspaceConstants'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
|
||||
const mockGetIdToken = vi.fn()
|
||||
|
||||
@@ -7,11 +7,11 @@ import { t } from '@/i18n'
|
||||
import {
|
||||
TOKEN_REFRESH_BUFFER_MS,
|
||||
WORKSPACE_STORAGE_KEYS
|
||||
} from '@/platform/auth/workspace/workspaceConstants'
|
||||
} from '@/platform/workspace/workspaceConstants'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const WorkspaceWithRoleSchema = z.object({
|
||||
@@ -250,6 +250,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
import { isVideoOutput } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
@@ -663,6 +664,7 @@ const nodeMedia = computed(() => {
|
||||
if (!urls?.length) return undefined
|
||||
|
||||
const type =
|
||||
isVideoOutput(newOutputs) ||
|
||||
node.previewMediaType === 'video' ||
|
||||
(!node.previewMediaType && hasVideoInput.value)
|
||||
? 'video'
|
||||
|
||||
@@ -85,11 +85,7 @@ describe('ComfyApp', () => {
|
||||
|
||||
const file1 = createTestFile('test1.png', 'image/png')
|
||||
const file2 = createTestFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const { files } = dataTransfer
|
||||
const files = [file1, file2]
|
||||
|
||||
await app.handleFileList(files)
|
||||
|
||||
@@ -110,26 +106,21 @@ describe('ComfyApp', () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null)
|
||||
|
||||
const file = createTestFile('test.png', 'image/png')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
await app.handleFileList([file])
|
||||
|
||||
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
|
||||
expect(mockNode1.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
|
||||
await expect(app.handleFileList([])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should not process unsupported file types', async () => {
|
||||
const invalidFile = createTestFile('test.pdf', 'application/pdf')
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(invalidFile)
|
||||
|
||||
await app.handleFileList(dataTransfer.files)
|
||||
await app.handleFileList([invalidFile])
|
||||
|
||||
expect(pasteImageNodes).not.toHaveBeenCalled()
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
||||
import {
|
||||
@@ -107,13 +108,13 @@ import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
import { type ComfyWidgetConstructor } from './widgets'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { extractFileFromDragEvent } from '@/utils/eventUtils'
|
||||
import { extractFilesFromDragEvent, hasImageType } from '@/utils/eventUtils'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
|
||||
|
||||
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
||||
|
||||
function sanitizeNodeName(string: string) {
|
||||
export function sanitizeNodeName(string: string) {
|
||||
let entityMap = {
|
||||
'&': '',
|
||||
'<': '',
|
||||
@@ -550,22 +551,25 @@ export class ComfyApp {
|
||||
// If you drag multiple files it will call it multiple times with the same file
|
||||
if (await n?.onDragDrop?.(event)) return
|
||||
|
||||
const fileMaybe = await extractFileFromDragEvent(event)
|
||||
if (!fileMaybe) return
|
||||
const files = await extractFilesFromDragEvent(event)
|
||||
if (files.length === 0) return
|
||||
|
||||
const workspace = useWorkspaceStore()
|
||||
try {
|
||||
workspace.spinner = true
|
||||
if (fileMaybe instanceof File) {
|
||||
await this.handleFile(fileMaybe, 'file_drop')
|
||||
}
|
||||
|
||||
if (fileMaybe instanceof FileList) {
|
||||
await this.handleFileList(fileMaybe)
|
||||
if (files.length > 1 && files.every(hasImageType)) {
|
||||
await this.handleFileList(files)
|
||||
} else {
|
||||
for (const file of files) {
|
||||
await this.handleFile(file, 'file_drop', {
|
||||
deferWarnings: true
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
workspace.spinner = false
|
||||
}
|
||||
useWorkflowService().showPendingWarnings()
|
||||
} catch (error: unknown) {
|
||||
useToastStore().addAlert(t('toastMessages.dropFileError', { error }))
|
||||
}
|
||||
@@ -1063,18 +1067,6 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
private showMissingModelsError(
|
||||
missingModels: ModelFile[],
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
|
||||
useDialogService().showMissingModelsWarning({
|
||||
missingModels,
|
||||
paths
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async loadGraphData(
|
||||
graphData?: ComfyWorkflowJSON,
|
||||
clean: boolean = true,
|
||||
@@ -1085,13 +1077,15 @@ export class ComfyApp {
|
||||
showMissingModelsDialog?: boolean
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
deferWarnings?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
showMissingNodesDialog = true,
|
||||
showMissingModelsDialog = true,
|
||||
checkForRerouteMigration = false,
|
||||
openSource
|
||||
openSource,
|
||||
deferWarnings = false
|
||||
} = options
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
@@ -1162,16 +1156,6 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
for (let n of nodes) {
|
||||
// When node replacement is disabled, fall back to hardcoded patches
|
||||
if (!nodeReplacementStore.isEnabled) {
|
||||
if (n.type == 'T2IAdapterLoader') n.type = 'ControlNetLoader'
|
||||
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage'
|
||||
if (n.type == 'SDV_img2vid_Conditioning')
|
||||
n.type = 'SVD_img2vid_Conditioning'
|
||||
if (n.type == 'Load3DAnimation') n.type = 'Load3D'
|
||||
if (n.type == 'Preview3DAnimation') n.type = 'Preview3D'
|
||||
}
|
||||
|
||||
// Find missing node types
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||
@@ -1344,13 +1328,6 @@ export class ComfyApp {
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
})
|
||||
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
this.showMissingNodesError(missingNodeTypes)
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
this.showMissingModelsError(missingModels, paths)
|
||||
}
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
@@ -1369,6 +1346,27 @@ export class ComfyApp {
|
||||
workflow,
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
|
||||
// Store pending warnings on the workflow for deferred display
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels: missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.canvas.setDirty(true, true)
|
||||
})
|
||||
@@ -1510,7 +1508,11 @@ export class ComfyApp {
|
||||
* Loads workflow data from the specified file
|
||||
* @param {File} file
|
||||
*/
|
||||
async handleFile(file: File, openSource?: WorkflowOpenSource) {
|
||||
async handleFile(
|
||||
file: File,
|
||||
openSource?: WorkflowOpenSource,
|
||||
options?: { deferWarnings?: boolean }
|
||||
) {
|
||||
const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
|
||||
const workflowData = await getWorkflowDataFromFile(file)
|
||||
const { workflow, prompt, parameters, templates } = workflowData ?? {}
|
||||
@@ -1553,7 +1555,8 @@ export class ComfyApp {
|
||||
!Array.isArray(workflowObj)
|
||||
) {
|
||||
await this.loadGraphData(workflowObj, true, true, fileName, {
|
||||
openSource
|
||||
openSource,
|
||||
deferWarnings: options?.deferWarnings
|
||||
})
|
||||
return
|
||||
} else {
|
||||
@@ -1601,7 +1604,7 @@ export class ComfyApp {
|
||||
* Loads multiple files, connects to a batch node, and selects them
|
||||
* @param {FileList} fileList
|
||||
*/
|
||||
async handleFileList(fileList: FileList) {
|
||||
async handleFileList(fileList: File[]) {
|
||||
if (fileList[0].type.startsWith('image')) {
|
||||
const imageNodes = await pasteImageNodes(this.canvas, fileList)
|
||||
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
|
||||
|
||||
@@ -5,7 +5,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -109,7 +109,10 @@ export const useDialogService = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
props
|
||||
props,
|
||||
footerProps: {
|
||||
missingNodeTypes: props.missingNodeTypes
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -568,7 +571,7 @@ export const useDialogService = () => {
|
||||
workspaceName?: string
|
||||
}) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/DeleteWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
@@ -581,7 +584,7 @@ export const useDialogService = () => {
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/CreateWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'create-workspace',
|
||||
component,
|
||||
@@ -598,7 +601,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showLeaveWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/LeaveWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
@@ -608,7 +611,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showEditWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/EditWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
@@ -624,7 +627,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showRemoveMemberDialog(memberId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/RemoveMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'remove-member',
|
||||
component,
|
||||
@@ -635,7 +638,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showInviteMemberDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/InviteMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member',
|
||||
component,
|
||||
@@ -651,7 +654,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showInviteMemberUpsellDialog() {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
@@ -667,7 +670,7 @@ export const useDialogService = () => {
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
|
||||
await import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
|
||||
@@ -56,8 +56,10 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
isAnimatedOutput,
|
||||
isImageNode,
|
||||
isVideoNode,
|
||||
isVideoOutput,
|
||||
migrateWidgetsValues
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
@@ -753,17 +755,9 @@ export const useLitegraphService = () => {
|
||||
if (isNewOutput) this.images = output.images
|
||||
|
||||
if (isNewOutput || isNewPreview) {
|
||||
this.animatedImages = output?.animated?.find(Boolean)
|
||||
this.animatedImages = isAnimatedOutput(output)
|
||||
|
||||
const isAnimatedWebp =
|
||||
this.animatedImages &&
|
||||
output?.images?.some((img) => img.filename?.includes('webp'))
|
||||
const isAnimatedPng =
|
||||
this.animatedImages &&
|
||||
output?.images?.some((img) => img.filename?.includes('png'))
|
||||
const isVideo =
|
||||
(this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
|
||||
isVideoNode(this)
|
||||
const isVideo = isVideoOutput(output) || isVideoNode(this)
|
||||
if (isVideo) {
|
||||
useNodeVideo(this, callback).showPreview()
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import * as litegraphUtil from '@/utils/litegraphUtil'
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isAnimatedOutput: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -150,13 +151,14 @@ describe('imagePreviewStore getPreviewParam', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
|
||||
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should return empty string if node.animatedImages is true', () => {
|
||||
it('should return empty string if output is animated', () => {
|
||||
const store = useNodeOutputStore()
|
||||
// @ts-expect-error `animatedImages` property is not typed
|
||||
const node = createMockNode({ animatedImages: true })
|
||||
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
import { isVideoNode } from '@/utils/litegraphUtil'
|
||||
import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
releaseSharedObjectUrl,
|
||||
retainSharedObjectUrl
|
||||
@@ -83,7 +83,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
outputs: ExecutedWsMessage['output']
|
||||
): boolean => {
|
||||
// If animated webp/png or video outputs, return false
|
||||
if (node.animatedImages || isVideoNode(node)) return false
|
||||
if (isAnimatedOutput(outputs) || isVideoNode(node)) return false
|
||||
|
||||
// If no images, return false
|
||||
if (!outputs?.images?.length) return false
|
||||
|
||||
@@ -1,39 +1,68 @@
|
||||
import { extractFileFromDragEvent } from '@/utils/eventUtils'
|
||||
import { extractFilesFromDragEvent } from '@/utils/eventUtils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('eventUtils', () => {
|
||||
describe('extractFileFromDragEvent', () => {
|
||||
it('should handle drops with no data', async () => {
|
||||
const actual = await extractFileFromDragEvent(new FakeDragEvent('drop'))
|
||||
expect(actual).toBe(undefined)
|
||||
describe('extractFilesFromDragEvent', () => {
|
||||
it('should return empty array when no dataTransfer', async () => {
|
||||
const actual = await extractFilesFromDragEvent(new FakeDragEvent('drop'))
|
||||
expect(actual).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle drops with dataTransfer but no files', async () => {
|
||||
const actual = await extractFileFromDragEvent(
|
||||
it('should return empty array when dataTransfer has no files', async () => {
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer: new DataTransfer() })
|
||||
)
|
||||
expect(actual).toBe(undefined)
|
||||
expect(actual).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle drops with dataTransfer with files', async () => {
|
||||
const fileWithWorkflowMaybeWhoKnows = new File(
|
||||
[new Uint8Array()],
|
||||
'fake_workflow.json',
|
||||
{
|
||||
type: 'application/json'
|
||||
}
|
||||
)
|
||||
|
||||
it('should return single file from dataTransfer', async () => {
|
||||
const file = new File([new Uint8Array()], 'workflow.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(fileWithWorkflowMaybeWhoKnows)
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file])
|
||||
})
|
||||
|
||||
it('should handle drops with multiple image files', async () => {
|
||||
it('should return multiple files from dataTransfer', async () => {
|
||||
const file1 = new File([new Uint8Array()], 'workflow1.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const file2 = new File([new Uint8Array()], 'workflow2.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
it('should filter out bmp files', async () => {
|
||||
const jsonFile = new File([new Uint8Array()], 'workflow.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const bmpFile = new File([new Uint8Array()], 'image.bmp', {
|
||||
type: 'image/bmp'
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(jsonFile)
|
||||
dataTransfer.items.add(bmpFile)
|
||||
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([jsonFile])
|
||||
})
|
||||
|
||||
it('should return multiple image files from dataTransfer', async () => {
|
||||
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
@@ -45,16 +74,13 @@ describe('eventUtils', () => {
|
||||
dataTransfer.items.add(imageFile1)
|
||||
dataTransfer.items.add(imageFile2)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBeDefined()
|
||||
expect((actual as FileList).length).toBe(2)
|
||||
expect((actual as FileList)[0]).toBe(imageFile1)
|
||||
expect((actual as FileList)[1]).toBe(imageFile2)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([imageFile1, imageFile2])
|
||||
})
|
||||
|
||||
it('should return undefined when dropping multiple non-image files', async () => {
|
||||
it('should return multiple non-image files from dataTransfer', async () => {
|
||||
const file1 = new File([new Uint8Array()], 'file1.txt', {
|
||||
type: 'text/plain'
|
||||
})
|
||||
@@ -66,10 +92,10 @@ describe('eventUtils', () => {
|
||||
dataTransfer.items.add(file1)
|
||||
dataTransfer.items.add(file2)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBe(undefined)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
// Skip until we can setup MSW
|
||||
@@ -77,14 +103,14 @@ describe('eventUtils', () => {
|
||||
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
dataTransfer.setData('text/uri-list', urlWithWorkflow)
|
||||
dataTransfer.setData('text/x-moz-url', urlWithWorkflow)
|
||||
|
||||
const event = new FakeDragEvent('drop', { dataTransfer })
|
||||
|
||||
const actual = await extractFileFromDragEvent(event)
|
||||
expect(actual).toBeInstanceOf(File)
|
||||
const actual = await extractFilesFromDragEvent(
|
||||
new FakeDragEvent('drop', { dataTransfer })
|
||||
)
|
||||
expect(actual.length).toBe(1)
|
||||
expect(actual[0]).toBeInstanceOf(File)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
export async function extractFileFromDragEvent(
|
||||
export async function extractFilesFromDragEvent(
|
||||
event: DragEvent
|
||||
): Promise<File | FileList | undefined> {
|
||||
if (!event.dataTransfer) return
|
||||
): Promise<File[]> {
|
||||
if (!event.dataTransfer) return []
|
||||
|
||||
const { files } = event.dataTransfer
|
||||
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
|
||||
if (files.length === 1 && files[0].type !== 'image/bmp') {
|
||||
return files[0]
|
||||
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
|
||||
return files
|
||||
}
|
||||
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
|
||||
const files = Array.from(event.dataTransfer.files).filter(
|
||||
(file) => file.type !== 'image/bmp'
|
||||
)
|
||||
|
||||
if (files.length > 0) return files
|
||||
|
||||
// Try loading the first URI in the transfer list
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
const match = [...event.dataTransfer.types].find((t) =>
|
||||
validTypes.includes(t)
|
||||
)
|
||||
if (!match) return
|
||||
if (!match) return []
|
||||
|
||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (!uri) return
|
||||
if (!uri) return []
|
||||
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return new File([blob], uri, { type: blob.type })
|
||||
return [new File([blob], uri, { type: blob.type })]
|
||||
}
|
||||
|
||||
function hasImageType({ type }: File): boolean {
|
||||
export function hasImageType({ type }: File): boolean {
|
||||
return type.startsWith('image')
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import type {
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
compressWidgetInputSlots,
|
||||
createNode,
|
||||
isAnimatedOutput,
|
||||
isVideoOutput,
|
||||
migrateWidgetsValues
|
||||
} from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -199,6 +202,106 @@ describe('migrateWidgetsValues', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function createOutput(
|
||||
overrides: Partial<ExecutedWsMessage['output']> = {}
|
||||
): ExecutedWsMessage['output'] {
|
||||
return { ...overrides }
|
||||
}
|
||||
|
||||
describe('isAnimatedOutput', () => {
|
||||
it('returns false for undefined output', () => {
|
||||
expect(isAnimatedOutput(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when animated array is missing', () => {
|
||||
expect(isAnimatedOutput(createOutput())).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when all animated values are false', () => {
|
||||
expect(isAnimatedOutput(createOutput({ animated: [false, false] }))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns true when any animated value is true', () => {
|
||||
expect(isAnimatedOutput(createOutput({ animated: [false, true] }))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVideoOutput', () => {
|
||||
it('returns false for non-animated output', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [false],
|
||||
images: [{ filename: 'video.webm' }]
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for animated webp output', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'anim.webp' }]
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for animated png output', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'anim.png' }]
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for animated webm output', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'output.webm' }]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for animated mp4 output', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'output.mp4' }]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for animated output with no images array', () => {
|
||||
expect(isVideoOutput(createOutput({ animated: [true] }))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not false-positive on filenames containing webp as substring', () => {
|
||||
expect(
|
||||
isVideoOutput(
|
||||
createOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'my_webp_file.mp4' }]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compressWidgetInputSlots', () => {
|
||||
it('should remove unconnected widget input slots', () => {
|
||||
// Using partial mock - only including properties needed for test
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
LGraph,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
@@ -77,6 +78,32 @@ export function isVideoNode(node: LGraphNode | undefined): node is VideoNode {
|
||||
return node.previewMediaType === 'video' || !!node.videoContainer
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output data indicates animated content (animated webp/png or video).
|
||||
*/
|
||||
export function isAnimatedOutput(
|
||||
output: ExecutedWsMessage['output'] | undefined
|
||||
): boolean {
|
||||
return !!output?.animated?.find(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output data indicates video content (animated but not webp/png).
|
||||
*/
|
||||
export function isVideoOutput(
|
||||
output: ExecutedWsMessage['output'] | undefined
|
||||
): boolean {
|
||||
if (!isAnimatedOutput(output)) return false
|
||||
|
||||
const isAnimatedWebp = output?.images?.some((img) =>
|
||||
img.filename?.endsWith('.webp')
|
||||
)
|
||||
const isAnimatedPng = output?.images?.some((img) =>
|
||||
img.filename?.endsWith('.png')
|
||||
)
|
||||
return !isAnimatedWebp && !isAnimatedPng
|
||||
}
|
||||
|
||||
export function isAudioNode(node: LGraphNode | undefined): boolean {
|
||||
return !!node && node.previewMediaType === 'audio'
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
|
||||
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useFavicon } from '@vueuse/core'
|
||||
|
||||
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
|
||||
import WorkspaceAuthGate from '@/platform/workspace/auth/WorkspaceAuthGate.vue'
|
||||
|
||||
useFavicon('/assets/favicon.ico')
|
||||
</script>
|
||||
|
||||