Compare commits

..

4 Commits

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

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

Fixes #8532
2026-02-11 01:22:54 +01:00
101 changed files with 346 additions and 3276 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

25
global.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
@@ -68,12 +68,6 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
@@ -145,7 +139,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -814,9 +814,6 @@
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"runningJobsLabel": "{count} running",
"queuedJobsLabel": "{count} queued",
"runningQueuedSummary": "{running}, {queued}",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
"viewJobHistory": "View active jobs (right-click to clear queue)",
@@ -2903,25 +2900,6 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
},
"nodeReplacement": {
"quickFixAvailable": "Quick Fix Available",
"installationRequired": "Installation Required",
"compatibleAlternatives": "Compatible Alternatives",
"replaceable": "Replaceable",
"replaced": "Replaced",
"notReplaceable": "Install Required",
"selectAll": "Select All",
"replaceSelected": "Replace Selected ({count})",
"replacedNode": "Replaced node: {nodeType}",
"replacedAllNodes": "Replaced {count} node type(s)",
"replaceFailed": "Failed to replace nodes",
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
"redHighlight": "red",
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",

View File

@@ -285,8 +285,8 @@
"name": "Show API node pricing badge"
},
"Comfy_NodeReplacement_Enabled": {
"name": "Enable node replacement suggestions",
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
"name": "Enable automatic node replacement",
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",

View File

@@ -249,8 +249,8 @@
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeReplacement_Enabled": {
"name": "노드 교체 제안 활성화",
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
"name": "자동 노드 교체 활성화",
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",

View File

@@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useWorkspaceAuthStore,
WorkspaceAuthError
} from '@/platform/workspace/stores/workspaceAuthStore'
} from '@/stores/workspaceAuthStore'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { WORKSPACE_STORAGE_KEYS } from './workspaceConstants'
const mockGetIdToken = vi.fn()

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())

View File

@@ -80,7 +80,7 @@ import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()

View File

@@ -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 '@/platform/workspace/stores/billingOperationStore'
import { useBillingOperationStore } from '@/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'

View File

@@ -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 '@/platform/workspace/stores/billingOperationStore'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'

View File

@@ -23,7 +23,7 @@ export const useSubscriptionDialog = () => {
const component = useWorkspaceVariant
? defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContentWorkspace.vue')
)
: defineAsyncComponent(
() =>

View File

@@ -15,18 +15,6 @@ 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) => {
@@ -39,10 +27,9 @@ function mockSettingStore(enabled: boolean) {
})
}
function createStore(enabled = true, featureEnabled = true) {
function createStore(enabled = true) {
setActivePinia(createPinia())
mockSettingStore(enabled)
mockNodeReplacementsEnabled.value = featureEnabled
return useNodeReplacementStore()
}
@@ -51,7 +38,6 @@ describe('useNodeReplacementStore', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodeReplacementsEnabled.value = true
store = createStore(true)
})
@@ -271,15 +257,5 @@ 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)
})
})
})

View File

@@ -3,7 +3,6 @@ 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'
@@ -15,12 +14,8 @@ 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

View File

@@ -1,654 +0,0 @@
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'])
})
})
})

View File

@@ -1,292 +0,0 @@
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
}
}

View File

@@ -27,7 +27,6 @@ type FirebaseRuntimeConfig = {
*/
export type RemoteConfig = {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert

View File

@@ -177,7 +177,7 @@ export function useSettingUI(
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
import('@/components/dialog/content/setting/WorkspacePanelContent.vue')
)
}

View File

@@ -1202,9 +1202,9 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.NodeReplacement.Enabled',
category: ['Comfy', 'Workflow', 'NodeReplacement'],
name: 'Enable node replacement suggestions',
name: 'Enable automatic node replacement',
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.',
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
type: 'boolean',
defaultValue: false,
experimental: true,

View File

@@ -1,69 +0,0 @@
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)
})
})

View File

@@ -22,21 +22,13 @@ export class GtmTelemetryProvider implements TelemetryProvider {
if (typeof window === 'undefined') return
const gtmId = window.__CONFIG__?.gtm_container_id
if (gtmId) {
this.initializeGtm(gtmId)
} else {
if (!gtmId) {
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({
@@ -52,38 +44,6 @@ 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 })

View File

@@ -9,37 +9,17 @@ describe('getCheckoutAttribution', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: undefined
}
window.gtag = undefined
window.__ga_identity__ = undefined
window.ire = undefined
window.history.pushState({}, '', '/')
})
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
window.__CONFIG__ = {
...window.__CONFIG__,
ga_measurement_id: 'G-TEST123'
window.__ga_identity__ = {
client_id: '123.456',
session_id: '1700000000',
session_number: '2'
}
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(
{},
'',
@@ -68,61 +48,6 @@ 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 () => {

View File

@@ -9,13 +9,6 @@ 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',
@@ -30,7 +23,6 @@ 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 {}
@@ -101,53 +93,19 @@ 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
}
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
): Promise<string | undefined> {
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
return undefined
}
const gtag = window.gtag
function getGaIdentity(): GaIdentity | undefined {
if (typeof window === 'undefined') 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
}
const identity = window.__ga_identity__
if (!isPlainObject(identity)) return undefined
return {
client_id: clientId,
session_id: sessionId,
session_number: sessionNumber
client_id: asNonEmptyString(identity.client_id),
session_id: asNonEmptyString(identity.session_id),
session_number: asNonEmptyString(identity.session_number)
}
}
@@ -212,7 +170,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const gaIdentity = await getGaIdentity()
const gaIdentity = getGaIdentity()
return {
...attribution,

View File

@@ -1,251 +0,0 @@
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)
})
})
})

View File

@@ -183,11 +183,9 @@ export const useWorkflowService = () => {
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: false,
deferWarnings: true
checkForRerouteMigration: false
}
)
showPendingWarnings()
}
/**
@@ -439,32 +437,6 @@ 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,
@@ -480,7 +452,6 @@ export const useWorkflowService = () => {
loadNextOpenedWorkflow,
loadPreviousOpenedWorkflow,
duplicateWorkflow,
showPendingWarnings,
afterLoadNewGraph,
beforeLoadNewGraph
}

View File

@@ -3,19 +3,7 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { UserFile } from '@/stores/userFileStore'
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[]>
}
}
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
export class ComfyWorkflow extends UserFile {
static readonly basePath: string = 'workflows/'
@@ -29,10 +17,6 @@ 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.

View File

@@ -25,7 +25,7 @@ const mockWorkspaceAuthStore = vi.hoisted(() => ({
clearWorkspaceContext: vi.fn()
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))

View File

@@ -1,10 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
import type {
ListMembersParams,

View File

@@ -250,7 +250,6 @@ 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
@@ -664,7 +663,6 @@ const nodeMedia = computed(() => {
if (!urls?.length) return undefined
const type =
isVideoOutput(newOutputs) ||
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput.value)
? 'video'

View File

@@ -85,7 +85,11 @@ describe('ComfyApp', () => {
const file1 = createTestFile('test1.png', 'image/png')
const file2 = createTestFile('test2.jpg', 'image/jpeg')
const files = [file1, file2]
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const { files } = dataTransfer
await app.handleFileList(files)
@@ -106,21 +110,26 @@ 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([file])
await app.handleFileList(dataTransfer.files)
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
expect(mockNode1.connect).not.toHaveBeenCalled()
})
it('should handle empty file list', async () => {
await expect(app.handleFileList([])).rejects.toThrow()
const dataTransfer = new DataTransfer()
await expect(app.handleFileList(dataTransfer.files)).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([invalidFile])
await app.handleFileList(dataTransfer.files)
expect(pasteImageNodes).not.toHaveBeenCalled()
expect(createNode).not.toHaveBeenCalled()

View File

@@ -24,7 +24,6 @@ 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 {
@@ -108,13 +107,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 { extractFilesFromDragEvent, hasImageType } from '@/utils/eventUtils'
import { extractFileFromDragEvent } from '@/utils/eventUtils'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
export function sanitizeNodeName(string: string) {
function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
'<': '',
@@ -551,25 +550,22 @@ 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 files = await extractFilesFromDragEvent(event)
if (files.length === 0) return
const fileMaybe = await extractFileFromDragEvent(event)
if (!fileMaybe) return
const workspace = useWorkspaceStore()
try {
workspace.spinner = true
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
})
}
if (fileMaybe instanceof File) {
await this.handleFile(fileMaybe, 'file_drop')
}
if (fileMaybe instanceof FileList) {
await this.handleFileList(fileMaybe)
}
} finally {
workspace.spinner = false
}
useWorkflowService().showPendingWarnings()
} catch (error: unknown) {
useToastStore().addAlert(t('toastMessages.dropFileError', { error }))
}
@@ -1067,6 +1063,18 @@ 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,
@@ -1077,15 +1085,13 @@ export class ComfyApp {
showMissingModelsDialog?: boolean
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
deferWarnings?: boolean
} = {}
) {
const {
showMissingNodesDialog = true,
showMissingModelsDialog = true,
checkForRerouteMigration = false,
openSource,
deferWarnings = false
openSource
} = options
useWorkflowService().beforeLoadNewGraph()
@@ -1156,6 +1162,16 @@ 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)
@@ -1328,6 +1344,13 @@ 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
@@ -1346,27 +1369,6 @@ 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)
})
@@ -1508,11 +1510,7 @@ export class ComfyApp {
* Loads workflow data from the specified file
* @param {File} file
*/
async handleFile(
file: File,
openSource?: WorkflowOpenSource,
options?: { deferWarnings?: boolean }
) {
async handleFile(file: File, openSource?: WorkflowOpenSource) {
const fileName = file.name.replace(/\.\w+$/, '') // Strip file extension
const workflowData = await getWorkflowDataFromFile(file)
const { workflow, prompt, parameters, templates } = workflowData ?? {}
@@ -1555,8 +1553,7 @@ export class ComfyApp {
!Array.isArray(workflowObj)
) {
await this.loadGraphData(workflowObj, true, true, fileName, {
openSource,
deferWarnings: options?.deferWarnings
openSource
})
return
} else {
@@ -1604,7 +1601,7 @@ export class ComfyApp {
* Loads multiple files, connects to a batch node, and selects them
* @param {FileList} fileList
*/
async handleFileList(fileList: File[]) {
async handleFileList(fileList: FileList) {
if (fileList[0].type.startsWith('image')) {
const imageNodes = await pasteImageNodes(this.canvas, fileList)
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')

View File

@@ -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 '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
import TopUpCreditsDialogContentWorkspace from '@/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
@@ -109,10 +109,7 @@ export const useDialogService = () => {
}
}
},
props,
footerProps: {
missingNodeTypes: props.missingNodeTypes
}
props
})
}
@@ -571,7 +568,7 @@ export const useDialogService = () => {
workspaceName?: string
}) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/DeleteWorkspaceDialogContent.vue')
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'delete-workspace',
component,
@@ -584,7 +581,7 @@ export const useDialogService = () => {
onConfirm?: (name: string) => void | Promise<void>
) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/CreateWorkspaceDialogContent.vue')
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'create-workspace',
component,
@@ -601,7 +598,7 @@ export const useDialogService = () => {
async function showLeaveWorkspaceDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/LeaveWorkspaceDialogContent.vue')
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'leave-workspace',
component,
@@ -611,7 +608,7 @@ export const useDialogService = () => {
async function showEditWorkspaceDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/EditWorkspaceDialogContent.vue')
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'edit-workspace',
component,
@@ -627,7 +624,7 @@ export const useDialogService = () => {
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RemoveMemberDialogContent.vue')
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'remove-member',
component,
@@ -638,7 +635,7 @@ export const useDialogService = () => {
async function showInviteMemberDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/InviteMemberDialogContent.vue')
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member',
component,
@@ -654,7 +651,7 @@ export const useDialogService = () => {
async function showInviteMemberUpsellDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue')
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member-upsell',
component,
@@ -670,7 +667,7 @@ export const useDialogService = () => {
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue')
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
return dialogStore.showDialog({
key: 'revoke-invite',
component,

View File

@@ -56,10 +56,8 @@ 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'
@@ -755,9 +753,17 @@ export const useLitegraphService = () => {
if (isNewOutput) this.images = output.images
if (isNewOutput || isNewPreview) {
this.animatedImages = isAnimatedOutput(output)
this.animatedImages = output?.animated?.find(Boolean)
const isVideo = isVideoOutput(output) || isVideoNode(this)
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)
if (isVideo) {
useNodeVideo(this, callback).showPreview()
} else {

View File

@@ -22,7 +22,7 @@ import { useFirebaseAuth } from 'vuefire'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'

View File

@@ -9,7 +9,6 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import * as litegraphUtil from '@/utils/litegraphUtil'
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn(),
isVideoNode: vi.fn()
}))
@@ -151,14 +150,13 @@ 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 output is animated', () => {
it('should return empty string if node.animatedImages is true', () => {
const store = useNodeOutputStore()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
const node = createMockNode()
// @ts-expect-error `animatedImages` property is not typed
const node = createMockNode({ animatedImages: true })
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()

View File

@@ -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 { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
import { 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 (isAnimatedOutput(outputs) || isVideoNode(node)) return false
if (node.animatedImages || isVideoNode(node)) return false
// If no images, return false
if (!outputs?.images?.length) return false

View File

@@ -7,11 +7,11 @@ import { t } from '@/i18n'
import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
} from '@/platform/auth/workspace/workspaceConstants'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const WorkspaceWithRoleSchema = z.object({

View File

@@ -1,68 +1,39 @@
import { extractFilesFromDragEvent } from '@/utils/eventUtils'
import { extractFileFromDragEvent } from '@/utils/eventUtils'
import { describe, expect, it } from 'vitest'
describe('eventUtils', () => {
describe('extractFilesFromDragEvent', () => {
it('should return empty array when no dataTransfer', async () => {
const actual = await extractFilesFromDragEvent(new FakeDragEvent('drop'))
expect(actual).toEqual([])
describe('extractFileFromDragEvent', () => {
it('should handle drops with no data', async () => {
const actual = await extractFileFromDragEvent(new FakeDragEvent('drop'))
expect(actual).toBe(undefined)
})
it('should return empty array when dataTransfer has no files', async () => {
const actual = await extractFilesFromDragEvent(
it('should handle drops with dataTransfer but no files', async () => {
const actual = await extractFileFromDragEvent(
new FakeDragEvent('drop', { dataTransfer: new DataTransfer() })
)
expect(actual).toEqual([])
expect(actual).toBe(undefined)
})
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(file)
const actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
it('should handle drops with dataTransfer with files', async () => {
const fileWithWorkflowMaybeWhoKnows = new File(
[new Uint8Array()],
'fake_workflow.json',
{
type: 'application/json'
}
)
expect(actual).toEqual([file])
})
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)
dataTransfer.items.add(fileWithWorkflowMaybeWhoKnows)
const actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
)
expect(actual).toEqual([file1, file2])
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
})
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 () => {
it('should handle drops with multiple image files', async () => {
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
type: 'image/png'
})
@@ -74,13 +45,16 @@ describe('eventUtils', () => {
dataTransfer.items.add(imageFile1)
dataTransfer.items.add(imageFile2)
const actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
)
expect(actual).toEqual([imageFile1, 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)
})
it('should return multiple non-image files from dataTransfer', async () => {
it('should return undefined when dropping multiple non-image files', async () => {
const file1 = new File([new Uint8Array()], 'file1.txt', {
type: 'text/plain'
})
@@ -92,10 +66,10 @@ describe('eventUtils', () => {
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
)
expect(actual).toEqual([file1, file2])
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBe(undefined)
})
// Skip until we can setup MSW
@@ -103,14 +77,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 actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
)
expect(actual.length).toBe(1)
expect(actual[0]).toBeInstanceOf(File)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBeInstanceOf(File)
})
})
})

View File

@@ -1,30 +1,31 @@
export async function extractFilesFromDragEvent(
export async function extractFileFromDragEvent(
event: DragEvent
): Promise<File[]> {
if (!event.dataTransfer) return []
): Promise<File | FileList | undefined> {
if (!event.dataTransfer) return
// 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
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
}
// 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 })
}
export function hasImageType({ type }: File): boolean {
function hasImageType({ type }: File): boolean {
return type.startsWith('image')
}

View File

@@ -9,12 +9,9 @@ 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'
@@ -202,106 +199,6 @@ 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

View File

@@ -5,7 +5,6 @@ import type {
LGraph,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
LGraphGroup,
LGraphNode,
@@ -78,32 +77,6 @@ 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'
}

View File

@@ -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 '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'

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