mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-27 18:17:19 +00:00
Compare commits
12 Commits
drjkl/new-
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff893d2408 | ||
|
|
4a2393be48 | ||
|
|
a451a90868 | ||
|
|
be102899d7 | ||
|
|
abd1a6f10a | ||
|
|
c16f10b49e | ||
|
|
64253de713 | ||
|
|
b4ae6344d7 | ||
|
|
feaa4ce82f | ||
|
|
f5d059b720 | ||
|
|
e2c670bbc3 | ||
|
|
dfcb336499 |
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -88,9 +88,9 @@ jobs:
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
'assets/images/*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
@@ -121,7 +121,8 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist a token with `workflow` scope so the backport push can
|
||||
# include changes to .github/workflows/**. The default GITHUB_TOKEN
|
||||
# is refused by GitHub when a push creates/updates workflow files,
|
||||
# which silently aborted the whole job (see PR #12804 backport).
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,8 @@ module.exports = defineConfig({
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
'fa',
|
||||
'he'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
@@ -37,5 +38,11 @@ module.exports = defineConfig({
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
|
||||
IMPORTANT Hebrew Translation Guidelines:
|
||||
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
|
||||
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
|
||||
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
|
||||
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
|
||||
`
|
||||
})
|
||||
|
||||
@@ -179,6 +179,9 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
26. Do not add alias helpers whose implementation is just a single-line call to another function
|
||||
- Bad: `function id(value) { return nodeId(value) }`
|
||||
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
|
||||
|
||||
## Design Standards
|
||||
|
||||
|
||||
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
export type VideoSource = {
|
||||
type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||
],
|
||||
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||
},
|
||||
{
|
||||
"source": "/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/:locale/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ export class VueNodeHelpers {
|
||||
.locator('.lg-slot--input')
|
||||
.filter({
|
||||
has: this.page.locator(
|
||||
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
|
||||
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -251,14 +252,18 @@ export class VueNodeHelpers {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
const [rawNodeId, type, slotId] = key.split('-')
|
||||
const nodeId = toNodeId(rawNodeId)
|
||||
return await this.page.evaluate(
|
||||
([nodeId, type, slotId]) => {
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)]?.links?.length
|
||||
},
|
||||
[nodeId, type, slotId] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selectedNodeIds = await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
return Object.keys(selected)
|
||||
})
|
||||
return selectedNodeIds.map(toNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
|
||||
return this.getNodeRefById(id)
|
||||
}
|
||||
|
||||
async getNodeRefById(id: NodeId): Promise<NodeReference> {
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
|
||||
return new NodeReference(toNodeId(id), this.comfyPage)
|
||||
}
|
||||
|
||||
async getNodeRefsByType(
|
||||
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class NodeOperationsHelper {
|
||||
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, title)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
@@ -45,9 +48,9 @@ async function orbitDragFromCanvasCenter(
|
||||
|
||||
export class Preview3DPipelineContext {
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly loadNodeId = '1'
|
||||
static readonly loadNodeId = toNodeId(1)
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly previewNodeId = '2'
|
||||
static readonly previewNodeId = toNodeId(2)
|
||||
|
||||
readonly load3d: Load3DHelper
|
||||
readonly preview3d: Load3DHelper
|
||||
@@ -61,9 +64,9 @@ export class Preview3DPipelineContext {
|
||||
)
|
||||
}
|
||||
|
||||
async getModelFileWidgetValue(nodeId: string): Promise<string> {
|
||||
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.widgets) return ''
|
||||
const w = node.widgets.find((x) => x.name === 'model_file')
|
||||
const v = w?.value
|
||||
@@ -71,9 +74,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getLastTimeModelFile(nodeId: string): Promise<string> {
|
||||
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return ''
|
||||
const v = (node.properties as Record<string, unknown>)[
|
||||
'Last Time Model File'
|
||||
@@ -82,9 +85,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
|
||||
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return null
|
||||
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
|
||||
if (cfg === null || typeof cfg !== 'object') return null
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
@@ -549,6 +550,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
@@ -565,7 +567,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
|
||||
type ComboInput = ComboInputSpec | ComboInputSpecV2
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
|
||||
|
||||
export interface PromotedMissingModelWorkflow {
|
||||
interface PromotedMissingModelWorkflow {
|
||||
workflowName: string
|
||||
hostNodeId: number
|
||||
hostNodeTitle: string
|
||||
@@ -418,7 +419,7 @@ async function enterSubgraphForStaleInteriorCheck(
|
||||
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
|
||||
}
|
||||
window.app!.canvas.setGraph(node.subgraph)
|
||||
}, numericNodeId)
|
||||
}, toNodeId(normalizedNodeId))
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
@@ -44,6 +45,7 @@ export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
@@ -91,7 +93,7 @@ export async function getPromotedWidgets(
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
localNodeId
|
||||
)
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const HOST_NODE_ID = toNodeId(2)
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -39,16 +41,22 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
function evaluateGraph() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const nodeIds = {
|
||||
switchCfg: toNodeId(120),
|
||||
ksampler85: toNodeId(85),
|
||||
ksampler86: toNodeId(86)
|
||||
}
|
||||
|
||||
return comfyPage.page.evaluate((nodeIds) => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
|
||||
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
|
||||
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
@@ -74,7 +82,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
if (
|
||||
String(link.origin_id) === '120' &&
|
||||
String(link.target_id) === '85'
|
||||
)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
@@ -89,7 +100,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
// Poll graph state once, then assert all properties
|
||||
|
||||
@@ -4,6 +4,9 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const IMAGE_COMPARE_NODE_ID = toNodeId(1)
|
||||
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -29,15 +32,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, value }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -450,11 +453,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
|
||||
const minWidth = 400
|
||||
const minHeight = 350
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
const size = await comfyPage.page.evaluate((nodeId) => {
|
||||
const graphNode = window.app!.graph.getNodeById(nodeId)
|
||||
if (!graphNode?.size) return null
|
||||
return { width: graphNode.size[0], height: graphNode.size[1] }
|
||||
})
|
||||
}, IMAGE_COMPARE_NODE_ID)
|
||||
expect(
|
||||
size,
|
||||
'ImageCompare node id 1 must exist in loaded workflow graph'
|
||||
@@ -600,15 +603,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, url }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -95,15 +96,15 @@ test.describe('Image Crop', () => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
{ nodeId: toNodeId(1), bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const getGizmoConfig = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
}, toNodeId(1))
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Load3D', () => {
|
||||
test(
|
||||
@@ -67,13 +68,13 @@ test.describe('Load3D', () => {
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe('#cc3333')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -331,7 +332,8 @@ wstest(
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
|
||||
toNodeId(1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
@@ -32,12 +34,13 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -245,8 +246,10 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
(nodeId) =>
|
||||
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
|
||||
?.length ?? 0,
|
||||
toNodeId(2)
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
selectVueAssetPromotedModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -384,11 +385,11 @@ test.describe(
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
|
||||
@@ -4,15 +4,16 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
type NodeSnapshot = { id: number } & Position
|
||||
type NodeSnapshot = { id: NodeId } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
@@ -21,7 +22,7 @@ async function getAllNodePositions(
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
nodeId: NodeId
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
|
||||
@@ -1095,6 +1095,33 @@ test.describe('Assets sidebar - drag and drop', () => {
|
||||
const fileComboWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
|
||||
test('Loading as workflow reuses asset name', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job',
|
||||
preview_output: {
|
||||
filename: `testimage.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
const path = comfyPage.assetPath('workflowInMedia/workflow.webp')
|
||||
await comfyPage.page.route('**/view?**', (route) => route.fulfill({ path }))
|
||||
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(1)
|
||||
|
||||
const targetPosition = { x: 400, y: 100 }
|
||||
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
|
||||
|
||||
const getTabName = () => comfyPage.menu.topbar.getActiveTabName()
|
||||
await expect.poll(getTabName).toContain('testimage')
|
||||
})
|
||||
})
|
||||
|
||||
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
|
||||
|
||||
@@ -198,7 +199,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootNodeTitle = await comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
|
||||
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
|
||||
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
|
||||
)
|
||||
expect(rootNodeTitle).toBe(newName)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface SubgraphNodePosition {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
@@ -57,12 +58,12 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('5')
|
||||
const subgraphNode = graph.getNodeById(nodeId)
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
@@ -260,17 +261,18 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.canvas.graph!.getNodeById(nodeId)!.progress,
|
||||
subgraphNodeId
|
||||
localSubgraphNodeId
|
||||
)
|
||||
)
|
||||
.toBe(0.5)
|
||||
@@ -287,7 +289,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
@@ -298,11 +300,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
@@ -39,9 +42,10 @@ async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
const localHostNodeId = toNodeId(hostNodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
const hostNode = graph.getNodeById(id)
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
@@ -80,7 +84,7 @@ async function getPrimitiveFanoutSnapshot(
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}, localHostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
@@ -103,19 +107,20 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const hostNodeId = toNodeId(hostSubgraphNodeId)
|
||||
const interiorNodeIds = widgets.map(([id]) => toNodeId(id))
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
const interiorNode = hostNode.subgraph.getNodeById(id)
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
[hostNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
@@ -570,8 +575,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.map((n) => String(n.id))
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
@@ -587,10 +591,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
return graph._nodes.map((n) => Number(n.id)).sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
@@ -633,18 +634,18 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
const SENTINEL_IDS = new Set(['-1', '-10', '-20'])
|
||||
const isSentinelNodeId = (id: number | string) =>
|
||||
SENTINEL_IDS.has(String(id))
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
id: NodeId,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
if (!g.getNodeById(id)) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
@@ -460,16 +461,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
|
||||
const subgraphNodeId = toNodeId(19)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((entry: { name: string }) =>
|
||||
entry.name.includes('seed')
|
||||
)
|
||||
return widget?.label || widget?.name || null
|
||||
})
|
||||
}, subgraphNodeId)
|
||||
)
|
||||
.toBe(RENAMED_LABEL)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
@@ -67,7 +67,7 @@ function slotLocator(
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const key = getSlotKey(String(nodeId), slotIndex, isInput)
|
||||
const key = getSlotKey(nodeId, slotIndex, isInput)
|
||||
return page.locator(`[data-slot-key="${key}"]`)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -59,12 +60,13 @@ async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const localLoadImageId = toNodeId(loadImageId)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
@@ -147,7 +149,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
|
||||
@@ -2,14 +2,15 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('Can display a slot mismatched from widget type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(5)!
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(nodeId)!
|
||||
emptyLatent.inputs[0].type = 'INT,FLOAT'
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const width = comfyPage.vueNodes
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
@@ -15,6 +16,7 @@ async function getCropValue(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<CropValue> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
|
||||
@@ -34,7 +36,7 @@ async function getCropValue(
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
async function setCropBounds(
|
||||
@@ -42,6 +44,7 @@ async function setCropBounds(
|
||||
nodeId: number,
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
await comfyPage.page.evaluate(
|
||||
({ id, b }) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
@@ -51,7 +54,7 @@ async function setCropBounds(
|
||||
w.callback?.(b)
|
||||
}
|
||||
},
|
||||
{ id: nodeId, b: bounds }
|
||||
{ id: localNodeId, b: bounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
@@ -17,7 +18,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
|
||||
toNodeId(10)
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
@@ -140,13 +138,13 @@ test.describe('Workflow settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
|
||||
async function getSerializedNodeIds(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeId[]> {
|
||||
): Promise<SerializedNodeId[]> {
|
||||
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
|
||||
(n) => n.id
|
||||
)
|
||||
}
|
||||
|
||||
function ascendingById(ids: NodeId[]): NodeId[] {
|
||||
function ascendingById(ids: SerializedNodeId[]): SerializedNodeId[] {
|
||||
return [...ids].sort((a, b) => Number(a) - Number(b))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Extensions are the primary way to add functionality to ComfyUI. They can be cust
|
||||
- Extension architecture principles
|
||||
- Hook execution sequence
|
||||
- Best practices for extension development
|
||||
- **[Node ID Migration Notes](./node-id-migration.md)** - Compatibility guidance for branded node IDs and subgraph boundary sentinel values
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
15
docs/extensions/node-id-migration.md
Normal file
15
docs/extensions/node-id-migration.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Node ID Migration Notes
|
||||
|
||||
ComfyUI frontend now normalizes local node IDs to the branded `NodeId` string
|
||||
type at internal boundaries. Serialized workflows and API payloads may still
|
||||
contain numeric IDs, but litegraph node and link fields should be treated as
|
||||
strings after they enter the frontend.
|
||||
|
||||
Extension authors should avoid numeric comparisons against node IDs. In
|
||||
particular, subgraph boundary sentinels are exposed as branded string IDs:
|
||||
|
||||
- `SUBGRAPH_INPUT_ID` serializes from `-10`
|
||||
- `SUBGRAPH_OUTPUT_ID` serializes from `-20`
|
||||
|
||||
Use the exported constants where available, or normalize both sides to strings
|
||||
before comparing legacy values.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.5",
|
||||
"version": "1.47.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
File diff suppressed because one or more lines are too long
16388
packages/ingest-types/src/types.gen.ts
generated
16388
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
4968
packages/ingest-types/src/zod.gen.ts
generated
4968
packages/ingest-types/src/zod.gen.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
@@ -83,7 +84,7 @@ function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
props: { nodeId: toNodeId('1'), modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
|
||||
@@ -145,8 +145,9 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
data-testid="subgraph-breadcrumb-missing-nodes-icon"
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<span class="p-breadcrumb-item-label max-w-72 px-2">{{ item.label }}</span>
|
||||
<Tag
|
||||
v-if="item.isBlueprint"
|
||||
data-testid="subgraph-breadcrumb-blueprint-tag"
|
||||
|
||||
@@ -12,8 +12,9 @@ import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelec
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
TitleMode
|
||||
@@ -132,7 +133,7 @@ function handleClick(e: MouseEvent) {
|
||||
if (!isSelectOutputsMode.value) return
|
||||
if (!node.constructor.nodeData?.output_node)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
|
||||
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
@@ -287,7 +288,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="
|
||||
() => remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
() => remove(appModeStore.selectedOutputs, (k) => k === key)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
@@ -347,7 +348,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-if="isSelected"
|
||||
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-warning-background p-2"
|
||||
@click.stop="
|
||||
remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
remove(appModeStore.selectedOutputs, (k) => k === key)
|
||||
"
|
||||
@pointerdown.stop
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
@@ -75,7 +76,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
matchingWidget.nodeId = node.id
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -139,7 +140,7 @@ async function handleDragDrop() {
|
||||
return false
|
||||
}
|
||||
|
||||
app.dragOverNode = { id: -1, onDragDrop }
|
||||
app.dragOverNode = { id: UNASSIGNED_NODE_ID, onDragDrop }
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
|
||||
48
src/components/common/DeviceInfo.test.ts
Normal file
48
src/components/common/DeviceInfo.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DeviceStats } from '@/schemas/apiSchema'
|
||||
|
||||
import DeviceInfo from './DeviceInfo.vue'
|
||||
|
||||
function createDevice(overrides: Partial<DeviceStats> = {}): DeviceStats {
|
||||
return {
|
||||
name: 'cuda:0 NVIDIA RTX',
|
||||
type: 'cuda',
|
||||
index: 0,
|
||||
vram_total: 1024,
|
||||
vram_free: 512,
|
||||
torch_vram_total: 2048,
|
||||
torch_vram_free: 256,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeviceInfo(device: DeviceStats) {
|
||||
return render(DeviceInfo, { props: { device } })
|
||||
}
|
||||
|
||||
describe('DeviceInfo', () => {
|
||||
it('renders device name and type as-is', () => {
|
||||
renderDeviceInfo(createDevice())
|
||||
|
||||
expect(screen.getByText('cuda:0 NVIDIA RTX')).toBeTruthy()
|
||||
expect(screen.getByText('cuda')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('formats vram fields as human-readable sizes', () => {
|
||||
renderDeviceInfo(
|
||||
createDevice({
|
||||
vram_total: 1024,
|
||||
vram_free: 0,
|
||||
torch_vram_total: 1048576,
|
||||
torch_vram_free: 1073741824
|
||||
})
|
||||
)
|
||||
|
||||
expect(screen.getByText('1 KB')).toBeTruthy()
|
||||
expect(screen.getByText('0 B')).toBeTruthy()
|
||||
expect(screen.getByText('1 MB')).toBeTruthy()
|
||||
expect(screen.getByText('1 GB')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
86
src/components/common/SystemStatsPanel.test.ts
Normal file
86
src/components/common/SystemStatsPanel.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
|
||||
import SystemStatsPanel from './SystemStatsPanel.vue'
|
||||
|
||||
const copyToClipboard = vi.fn()
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({ copyToClipboard })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function createStats(
|
||||
overrides: Partial<SystemStats['system']> = {}
|
||||
): SystemStats {
|
||||
return {
|
||||
system: {
|
||||
os: 'posix',
|
||||
python_version: '3.12.4',
|
||||
embedded_python: false,
|
||||
comfyui_version: 'v1.2.3',
|
||||
pytorch_version: '2.4.0',
|
||||
argv: ['main.py', '--listen'],
|
||||
ram_total: 1024,
|
||||
ram_free: 512,
|
||||
installed_templates_version: '1.0.0',
|
||||
required_templates_version: '1.0.0',
|
||||
...overrides
|
||||
},
|
||||
devices: []
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(stats: SystemStats) {
|
||||
return render(SystemStatsPanel, {
|
||||
props: { stats },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Divider: true, TabView: true, TabPanel: true, DeviceInfo: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SystemStatsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders localized headers with corrected PyTorch casing', () => {
|
||||
renderPanel(createStats())
|
||||
|
||||
expect(screen.getByText('PyTorch Version')).toBeTruthy()
|
||||
expect(screen.queryByText('Pytorch Version')).toBeNull()
|
||||
expect(screen.getByText('OS')).toBeTruthy()
|
||||
expect(screen.getByText('Python Version')).toBeTruthy()
|
||||
expect(screen.getByText('Arguments')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('formats values for display', () => {
|
||||
renderPanel(createStats({ ram_total: 1024, argv: ['main.py', '--cpu'] }))
|
||||
|
||||
expect(screen.getByText('1 KB')).toBeTruthy()
|
||||
expect(screen.getByText('main.py --cpu')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies localized, formatted system info to the clipboard', async () => {
|
||||
renderPanel(createStats())
|
||||
|
||||
await userEvent.click(screen.getByText(enMessages.g.copySystemInfo))
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(1)
|
||||
const copied = copyToClipboard.mock.calls[0][0] as string
|
||||
expect(copied).toContain('## System Info')
|
||||
expect(copied).toContain('PyTorch Version: 2.4.0')
|
||||
expect(copied).toContain('RAM Total: 1 KB')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
{{ col.header }}
|
||||
{{ $t(col.headerKey) }}
|
||||
</div>
|
||||
<div :class="cn(isOutdated(col) && 'text-danger-100')">
|
||||
{{ getDisplayValue(col) }}
|
||||
@@ -58,8 +58,10 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -78,7 +80,7 @@ type SystemInfoKey = keyof SystemStats['system']
|
||||
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
headerKey: string
|
||||
getValue?: () => string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
@@ -86,31 +88,45 @@ type ColumnDef = {
|
||||
|
||||
/** Columns for local distribution */
|
||||
const localColumns: ColumnDef[] = [
|
||||
{ field: 'os', header: 'OS' },
|
||||
{ field: 'python_version', header: 'Python Version' },
|
||||
{ field: 'embedded_python', header: 'Embedded Python' },
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
|
||||
{ field: 'installed_templates_version', header: 'Templates Version' }
|
||||
{ field: 'os', headerKey: 'g.systemStatsOS' },
|
||||
{ field: 'python_version', headerKey: 'g.systemStatsPythonVersion' },
|
||||
{ field: 'embedded_python', headerKey: 'g.systemStatsEmbeddedPython' },
|
||||
{ field: 'pytorch_version', headerKey: 'g.systemStatsPyTorchVersion' },
|
||||
{ field: 'argv', headerKey: 'g.systemStatsArguments' },
|
||||
{
|
||||
field: 'ram_total',
|
||||
headerKey: 'g.systemStatsRAMTotal',
|
||||
formatNumber: formatSize
|
||||
},
|
||||
{
|
||||
field: 'ram_free',
|
||||
headerKey: 'g.systemStatsRAMFree',
|
||||
formatNumber: formatSize
|
||||
},
|
||||
{
|
||||
field: 'installed_templates_version',
|
||||
headerKey: 'g.systemStatsTemplatesVersion'
|
||||
}
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
const cloudColumns: ColumnDef[] = [
|
||||
{ field: 'cloud_version', header: 'Cloud Version' },
|
||||
{ field: 'cloud_version', headerKey: 'g.systemStatsCloudVersion' },
|
||||
{
|
||||
field: 'comfyui_version',
|
||||
header: 'ComfyUI Version',
|
||||
headerKey: 'g.systemStatsComfyUIVersion',
|
||||
format: formatCommitHash
|
||||
},
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
headerKey: 'g.systemStatsFrontendVersion',
|
||||
getValue: () => frontendCommit,
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
{
|
||||
field: 'workflow_templates_version',
|
||||
headerKey: 'g.systemStatsTemplatesVersion'
|
||||
}
|
||||
]
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
@@ -141,7 +157,7 @@ function formatSystemInfoText(): string {
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
lines.push(`${t(col.headerKey)}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -239,7 +240,7 @@ describe('WidgetCurve', () => {
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
linkedUpstream: { nodeId: toNodeId('n1') }
|
||||
})
|
||||
)
|
||||
const parsed = JSON.parse(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
type TestWidget = BaseDOMWidget<object | string>
|
||||
|
||||
@@ -21,7 +22,7 @@ function createNode(
|
||||
pos: [number, number]
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = id
|
||||
node.id = toNodeId(id)
|
||||
node.pos = [...pos]
|
||||
node.size = [240, 120]
|
||||
graph.add(node)
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:error="
|
||||
executionErrorStore.lastExecutionError?.node_id === nodeData.id
|
||||
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
|
||||
@@ -3,6 +3,9 @@ import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const execHolder = vi.hoisted(() => ({
|
||||
state: null as {
|
||||
executingNodeIds: Array<string | number>
|
||||
@@ -35,7 +38,7 @@ const SkeletonStub = defineComponent({
|
||||
|
||||
function renderPreview(
|
||||
text: string,
|
||||
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
|
||||
{ nodeId = toNodeId('node-1') }: { nodeId?: NodeId } = {}
|
||||
) {
|
||||
const value = ref(text)
|
||||
const Harness = defineComponent({
|
||||
@@ -167,21 +170,21 @@ describe('TextPreviewWidget', () => {
|
||||
it('hides the Skeleton on mount when execution is already idle', () => {
|
||||
execState().executingNodeIds = []
|
||||
execState().isIdle = true
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
renderPreview('text', { nodeId: toNodeId('n1') })
|
||||
expect(screen.queryByTestId('skeleton')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a Skeleton on mount when the parent node is executing', () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
renderPreview('text', { nodeId: toNodeId('n1') })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Skeleton when execution transitions to idle', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
renderPreview('text', { nodeId: toNodeId('n1') })
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
|
||||
execState().executingNodeIds = []
|
||||
@@ -194,7 +197,7 @@ describe('TextPreviewWidget', () => {
|
||||
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
|
||||
execState().executingNodeIds = ['n1']
|
||||
execState().isIdle = false
|
||||
renderPreview('text', { nodeId: 'n1' })
|
||||
renderPreview('text', { nodeId: toNodeId('n1') })
|
||||
|
||||
execState().executingNodeIds = ['other']
|
||||
await nextTick()
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<script setup lang="ts">
|
||||
import { default as DOMPurify } from 'dompurify'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
@@ -28,8 +28,7 @@ const props = defineProps<{
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = computed(() => {
|
||||
if (executionStore.isIdle) return false
|
||||
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
|
||||
return executionStore.executingNodeIds.includes(parentNodeId)
|
||||
return executionStore.executingNodeIds.includes(props.nodeId)
|
||||
})
|
||||
const formattedText = computed(() => {
|
||||
const src = modelValue.value
|
||||
@@ -64,19 +63,4 @@ const formattedText = computed(() => {
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel']
|
||||
})
|
||||
})
|
||||
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId ?? parentNodeId
|
||||
})
|
||||
|
||||
// Lazily adopt the first executing node as the parent when no nodeId is known.
|
||||
watch(
|
||||
() => executionStore.executingNodeIds,
|
||||
(ids) => {
|
||||
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -132,11 +133,12 @@ function renderWidget(
|
||||
initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 }
|
||||
) {
|
||||
const value = ref<Bounds>(initialModel)
|
||||
const nodeId = toNodeId(1)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetImageCrop },
|
||||
setup: () => ({ value, widget }),
|
||||
setup: () => ({ value, widget, nodeId }),
|
||||
template:
|
||||
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="1" />'
|
||||
'<WidgetImageCrop v-model="value" :widget="widget" :node-id="nodeId" />'
|
||||
})
|
||||
const utils = render(Harness, {
|
||||
global: {
|
||||
@@ -233,7 +235,7 @@ describe('WidgetImageCrop', () => {
|
||||
renderWidget(
|
||||
makeWidget({
|
||||
options: { disabled: true },
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
linkedUpstream: { nodeId: toNodeId('n1') }
|
||||
}),
|
||||
{ x: 0, y: 0, width: 512, height: 512 }
|
||||
)
|
||||
|
||||
@@ -135,8 +135,8 @@ import {
|
||||
boundsExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
|
||||
load3dState: {
|
||||
@@ -83,7 +85,7 @@ const i18n = createI18n({
|
||||
|
||||
type RenderOptions = {
|
||||
widget?: unknown
|
||||
nodeId?: number | string
|
||||
nodeId?: NodeId
|
||||
stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>>
|
||||
enable3DViewer?: boolean
|
||||
}
|
||||
@@ -165,16 +167,17 @@ describe('Load3D', () => {
|
||||
})
|
||||
|
||||
it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => {
|
||||
const nodeId = toNodeId(42)
|
||||
resolveNodeMock.mockReturnValue(MOCK_NODE)
|
||||
renderLoad3D({ widget: {}, nodeId: 42 })
|
||||
renderLoad3D({ widget: {}, nodeId })
|
||||
|
||||
expect(resolveNodeMock).toHaveBeenCalledWith(42)
|
||||
expect(resolveNodeMock).toHaveBeenCalledWith(nodeId)
|
||||
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render Load3DScene when no node can be resolved', async () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 99 })
|
||||
renderLoad3D({ widget: {}, nodeId: toNodeId(99) })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument()
|
||||
@@ -219,7 +222,11 @@ describe('Load3D', () => {
|
||||
|
||||
it('hides ViewerControls when there is no node even if the setting is on', () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true })
|
||||
renderLoad3D({
|
||||
widget: {},
|
||||
nodeId: toNodeId(1),
|
||||
enable3DViewer: true
|
||||
})
|
||||
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,10 +115,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const {
|
||||
widget,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { render } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const lastProps = ref<Record<string, unknown> | null>(null)
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({
|
||||
@@ -39,9 +41,10 @@ describe('Load3DAdvanced', () => {
|
||||
})
|
||||
|
||||
it('forwards widget and nodeId to the inner Load3D', () => {
|
||||
const nodeId = toNodeId('a')
|
||||
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
|
||||
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
|
||||
render(Load3DAdvanced, { props: { widget: widget as never, nodeId } })
|
||||
expect(lastProps.value?.widget).toEqual(widget)
|
||||
expect(lastProps.value?.nodeId).toBe('a')
|
||||
expect(lastProps.value?.nodeId).toBe(nodeId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -289,11 +289,12 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { toHexFromFormat } from '@/utils/colorUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { nodeId } = defineProps<{
|
||||
nodeId: string
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -135,7 +136,7 @@ describe('WidgetRange', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(
|
||||
makeWidget({ disabled: true } as IWidgetRangeOptions, {
|
||||
linkedUpstream: { nodeId: 'n1' }
|
||||
linkedUpstream: { nodeId: toNodeId('n1') }
|
||||
}),
|
||||
{ min: 0, max: 1 }
|
||||
)
|
||||
@@ -145,10 +146,13 @@ describe('WidgetRange', () => {
|
||||
|
||||
it('ignores upstream value when not disabled', () => {
|
||||
setUpstream({ min: 0.3, max: 0.7 })
|
||||
renderWidget(makeWidget({}, { linkedUpstream: { nodeId: 'n1' } }), {
|
||||
min: 0,
|
||||
max: 1
|
||||
})
|
||||
renderWidget(
|
||||
makeWidget({}, { linkedUpstream: { nodeId: toNodeId('n1') } }),
|
||||
{
|
||||
min: 0,
|
||||
max: 1
|
||||
}
|
||||
)
|
||||
const el = screen.getByTestId('range-editor')
|
||||
expect(JSON.parse(el.dataset.model!)).toEqual({ min: 0, max: 1 })
|
||||
})
|
||||
@@ -167,7 +171,10 @@ describe('WidgetRange', () => {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
makeWidget(
|
||||
{},
|
||||
{ nodeLocatorId: createNodeLocatorId(null, toNodeId('loc1')) }
|
||||
)
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
@@ -179,7 +186,10 @@ describe('WidgetRange', () => {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
makeWidget(
|
||||
{},
|
||||
{ nodeLocatorId: createNodeLocatorId(null, toNodeId('loc1')) }
|
||||
)
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -24,7 +25,7 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: createNodeExecutionId([toNodeId(10)]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
errors: [
|
||||
{
|
||||
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeId: createNodeExecutionId([toNodeId(24)]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
errors: [
|
||||
{
|
||||
@@ -54,7 +55,7 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeId: createNodeExecutionId([toNodeId(45)]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -70,6 +71,19 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([toNodeId(3), toNodeId(15)]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -87,6 +101,12 @@ export const SingleValidationError: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const NestedNodeError: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: createNodeExecutionId([toNodeId(10)]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: createNodeExecutionId([toNodeId(10)]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: createNodeExecutionId([toNodeId(10)]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
@@ -104,7 +105,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: createNodeExecutionId([toNodeId(42)]),
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -182,7 +183,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: createNodeExecutionId([toNodeId(42)]),
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
@@ -81,7 +82,7 @@ function createHostWithPromotedModel(): {
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
sourceNode.id = 42
|
||||
sourceNode.id = toNodeId(42)
|
||||
const sourceInput = sourceNode.addInput('ckpt_name', 'COMBO')
|
||||
const sourceWidget = sourceNode.addWidget(
|
||||
'combo',
|
||||
|
||||
@@ -151,10 +151,11 @@ function isWidgetShownOnParents(
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (source) {
|
||||
const widgetNodeId = widgetNode.id
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? source.nodeId
|
||||
: String(widgetNode.id)
|
||||
: widgetNodeId
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
@@ -162,7 +163,7 @@ function isWidgetShownOnParents(
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceNodeId: widgetNode.id,
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,12 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
|
||||
@@ -3,11 +3,12 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
@@ -38,7 +39,11 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
const advancedWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
w.options?.hideInPanel
|
||||
) && w.options?.advanced
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: advancedWidgets, node }
|
||||
|
||||
@@ -82,7 +82,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!isWidgetPromotedOnSubgraphNode(node, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceNodeId: interiorNode.id,
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -90,9 +90,10 @@ function handleHideInput() {
|
||||
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
const currentNodeId = node.id
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
String(node.id) === String(parent.id) ? source.nodeId : currentNodeId
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn(),
|
||||
@@ -145,7 +146,7 @@ describe('WidgetItem', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
@@ -160,7 +161,7 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
@@ -175,7 +176,7 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
@@ -190,7 +191,7 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
|
||||
@@ -70,7 +70,7 @@ const widgetComponent = computed(() => {
|
||||
|
||||
const isLinked = computed(() => {
|
||||
const safeWidget = useVueNodeLifecycle()
|
||||
.nodeManager.value?.vueNodeData.get(String(node.id))
|
||||
.nodeManager.value?.vueNodeData.get(node.id)
|
||||
?.widgets?.find((w) => w.name === widget.name)
|
||||
return safeWidget?.slotMetadata
|
||||
? !!safeWidget.slotMetadata.linked
|
||||
@@ -79,10 +79,10 @@ const isLinked = computed(() => {
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const bareNodeId = stripGraphPrefix(node.id)
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
: graphId && bareNodeId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
@@ -212,7 +212,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
:is="widgetComponent"
|
||||
v-model="widgetValue"
|
||||
:widget="simplifiedWidget"
|
||||
:node-id="String(node.id)"
|
||||
:node-id="node.id"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
computedSectionDataList,
|
||||
flatAndCategorizeSelectedItems,
|
||||
searchWidgets,
|
||||
searchWidgetsAndNodes
|
||||
} from './shared'
|
||||
import type { NodeWidgetsListList } from './shared'
|
||||
|
||||
describe('searchWidgets', () => {
|
||||
const createWidget = (
|
||||
function createWidget(
|
||||
name: string,
|
||||
type: string,
|
||||
value?: string,
|
||||
label?: string
|
||||
): { widget: IBaseWidget } => ({
|
||||
widget: {
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
label
|
||||
} as IBaseWidget
|
||||
})
|
||||
): { widget: IBaseWidget } {
|
||||
return {
|
||||
widget: {
|
||||
name,
|
||||
options: {},
|
||||
type,
|
||||
value,
|
||||
label,
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should return all widgets when query is empty', () => {
|
||||
const widgets = [
|
||||
@@ -71,6 +87,99 @@ describe('searchWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchWidgetsAndNodes', () => {
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
options: {},
|
||||
type: 'number',
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
|
||||
function createNodeSection(
|
||||
id: number,
|
||||
title: string,
|
||||
widgetNames: string[]
|
||||
): NodeWidgetsListList[number] {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = toNodeId(id)
|
||||
const widgets = widgetNames.map((name) => ({
|
||||
node,
|
||||
widget: createWidget(name)
|
||||
}))
|
||||
|
||||
return { node, widgets }
|
||||
}
|
||||
|
||||
it('keeps all widgets for matching nodes and filters widgets for other nodes', () => {
|
||||
const matchingNode = createNodeSection(1, 'Image Size', ['width', 'height'])
|
||||
const matchingWidget = createNodeSection(2, 'Sampler', [
|
||||
'seed',
|
||||
'imageQuality'
|
||||
])
|
||||
const hiddenNode = createNodeSection(3, 'Preview', ['scale'])
|
||||
|
||||
const result = searchWidgetsAndNodes(
|
||||
[matchingNode, matchingWidget, hiddenNode],
|
||||
'image'
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
matchingNode,
|
||||
{
|
||||
...matchingWidget,
|
||||
widgets: [matchingWidget.widgets[1]]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computedSectionDataList', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
options: IWidgetOptions = {}
|
||||
): IBaseWidget {
|
||||
return { name, type: 'number', options, y: 0 } as IBaseWidget
|
||||
}
|
||||
|
||||
it('omits hideInPanel widgets while keeping the rest on the node', () => {
|
||||
const node = new LGraphNode('Load3D')
|
||||
node.widgets = [
|
||||
createWidget('seed'),
|
||||
createWidget('viewport', { hideInPanel: true })
|
||||
]
|
||||
|
||||
const { widgetsSectionDataList } = computedSectionDataList([node])
|
||||
const shownNames = widgetsSectionDataList.value[0].widgets.map(
|
||||
({ widget }) => widget.name
|
||||
)
|
||||
|
||||
expect(shownNames).toEqual(['seed'])
|
||||
})
|
||||
|
||||
it('hides canvasOnly, hidden, and hideInPanel widgets from the panel', () => {
|
||||
const node = new LGraphNode('Load3D')
|
||||
node.widgets = [
|
||||
createWidget('seed'),
|
||||
createWidget('preview', { canvasOnly: true }),
|
||||
createWidget('internal', { hidden: true }),
|
||||
createWidget('viewport', { hideInPanel: true })
|
||||
]
|
||||
|
||||
const { widgetsSectionDataList } = computedSectionDataList([node])
|
||||
const shownNames = widgetsSectionDataList.value[0].widgets.map(
|
||||
({ widget }) => widget.name
|
||||
)
|
||||
|
||||
expect(shownNames).toEqual(['seed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flatAndCategorizeSelectedItems', () => {
|
||||
let testGroup1: LGraphGroup
|
||||
let testGroup2: LGraphGroup
|
||||
|
||||
@@ -5,8 +5,9 @@ import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
@@ -107,18 +108,14 @@ export function searchWidgetsAndNodes(
|
||||
nodeMatches.map((result) => result.item.nodeId)
|
||||
)
|
||||
|
||||
return list
|
||||
.map((item) => {
|
||||
if (matchedNodeIds.has(item.node.id)) {
|
||||
return { ...item, keep: true }
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
keep: false,
|
||||
widgets: searchWidgets(item.widgets, query)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.keep || item.widgets.length > 0)
|
||||
return list.flatMap((item) => {
|
||||
if (matchedNodeIds.has(item.node.id)) {
|
||||
return [item]
|
||||
}
|
||||
|
||||
const widgets = searchWidgets(item.widgets, query)
|
||||
return widgets.length > 0 ? [{ ...item, widgets }] : []
|
||||
})
|
||||
}
|
||||
|
||||
type MixedSelectionItem = LGraphGroup | LGraphNode
|
||||
@@ -266,6 +263,7 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
w.options?.hideInPanel ||
|
||||
(w.options?.advanced && !includesAdvanced.value)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
@@ -116,7 +117,7 @@ function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
|
||||
return exposures.flatMap((exposure): PreviewRow[] => {
|
||||
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
|
||||
const sourceNode = node.subgraph.getNodeById(exposure.sourceNodeId)
|
||||
if (!sourceNode) return []
|
||||
const realWidget = getPromotableWidgets(sourceNode).find(
|
||||
(candidate) => candidate.name === exposure.sourcePreviewName
|
||||
@@ -248,7 +249,7 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
if (row.node.id === UNASSIGNED_NODE_ID) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
|
||||
@@ -4,8 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
|
||||
import MediaLightbox from './MediaLightbox.vue'
|
||||
|
||||
@@ -28,7 +28,7 @@ type MockResultItem = Partial<ResultItemImpl> & {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
nodeId: NodeId
|
||||
nodeId: SerializedNodeId
|
||||
mediaType: string
|
||||
id?: string
|
||||
url?: string
|
||||
@@ -63,7 +63,7 @@ describe('MediaLightbox', () => {
|
||||
filename: 'image1.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '123' as NodeId,
|
||||
nodeId: '123',
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
@@ -75,7 +75,7 @@ describe('MediaLightbox', () => {
|
||||
filename: 'image2.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '456' as NodeId,
|
||||
nodeId: '456',
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
@@ -87,7 +87,7 @@ describe('MediaLightbox', () => {
|
||||
filename: 'image3.jpg',
|
||||
subfolder: 'outputs',
|
||||
type: 'output',
|
||||
nodeId: '789' as NodeId,
|
||||
nodeId: '789',
|
||||
mediaType: 'images',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
@@ -103,7 +104,7 @@ function setup(initial: BoundingBox[] = []) {
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial)
|
||||
const api = useBoundingBoxes('1', {
|
||||
const api = useBoundingBoxes(toNodeId('1'), {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
|
||||
|
||||
const HANDLE_PX = 8
|
||||
@@ -39,7 +40,7 @@ interface UseBoundingBoxesOptions {
|
||||
}
|
||||
|
||||
export function useBoundingBoxes(
|
||||
nodeId: string,
|
||||
nodeId: NodeId,
|
||||
{
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
@@ -63,9 +64,7 @@ export function useBoundingBoxes(
|
||||
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
|
||||
)
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isNodeSelected = computed(() =>
|
||||
selectedNodeIds.value.has(String(nodeId))
|
||||
)
|
||||
const isNodeSelected = computed(() => selectedNodeIds.value.has(nodeId))
|
||||
|
||||
function dimWidget(name: 'width' | 'height'): number | undefined {
|
||||
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
@@ -38,7 +39,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
// unmodified — the node accessors filter selectedItems with the real predicate.
|
||||
const makeNode = (mode: LGraphEventMode, id = 1): LGraphNode => {
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = id
|
||||
node.id = toNodeId(id)
|
||||
node.mode = mode
|
||||
return node
|
||||
}
|
||||
@@ -69,7 +70,7 @@ class MockNode implements Positionable {
|
||||
) {
|
||||
this.pos = pos
|
||||
this.size = size
|
||||
this.id = 'mock-node'
|
||||
this.id = toNodeId('mock-node')
|
||||
this.boundingRect = [0, 0, 0, 0]
|
||||
}
|
||||
|
||||
|
||||
91
src/composables/canvas/useSelectionToolboxPosition.test.ts
Normal file
91
src/composables/canvas/useSelectionToolboxPosition.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h, markRaw, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: null
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionToolboxPosition', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set([item]),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
let toolbox: HTMLElement | undefined
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
|
||||
toolbox = toolboxRef.value
|
||||
useSelectionToolboxPosition(toolboxRef)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = render(TestHarness)
|
||||
if (!toolbox) throw new Error('Toolbox element was not initialized')
|
||||
|
||||
return { toolbox, unmount: wrapper.unmount }
|
||||
}
|
||||
|
||||
it('positions groups from their unchanged bounds', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions nodes from bounds that include the title bar', () => {
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(1)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,42 @@ function currentSelectionMatchesSignature(
|
||||
return buildSelectionSignature(store) === moreOptionsSelectionSignature
|
||||
}
|
||||
|
||||
function getFullNodeBounds(item: LGraphNode | LGraphGroup): ReadOnlyRect {
|
||||
if (item instanceof LGraphGroup) {
|
||||
return [item.pos[0], item.pos[1], item.size[0], item.size[1]]
|
||||
}
|
||||
|
||||
return [
|
||||
item.pos[0],
|
||||
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
|
||||
item.size[0],
|
||||
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
|
||||
]
|
||||
}
|
||||
|
||||
function getVueNodeBounds(item: LGraphNode): ReadOnlyRect | null {
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (!layout) return null
|
||||
|
||||
return [
|
||||
layout.bounds.x,
|
||||
layout.bounds.y - LiteGraph.NODE_TITLE_HEIGHT,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
]
|
||||
}
|
||||
|
||||
function getSelectionBounds(
|
||||
item: LGraphNode | LGraphGroup,
|
||||
shouldUseVueLayout: boolean
|
||||
): ReadOnlyRect {
|
||||
if (shouldUseVueLayout && item instanceof LGraphNode) {
|
||||
return getVueNodeBounds(item) ?? getFullNodeBounds(item)
|
||||
}
|
||||
|
||||
return getFullNodeBounds(item)
|
||||
}
|
||||
|
||||
export function useSelectionToolboxPosition(
|
||||
toolboxRef: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
@@ -99,27 +135,8 @@ export function useSelectionToolboxPosition(
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
||||
// Use layout store for Vue nodes (only works with string IDs)
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
|
||||
allBounds.push([
|
||||
item.pos[0],
|
||||
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
|
||||
item.size[0],
|
||||
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
|
||||
])
|
||||
}
|
||||
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
|
||||
allBounds.push(getSelectionBounds(item, shouldRenderVueNodes.value))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface MockNodeSpec {
|
||||
id: number | string
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
title_mode?: TitleMode
|
||||
}
|
||||
|
||||
const makeNode = (spec: MockNodeSpec): LGraphNode =>
|
||||
({
|
||||
id: spec.id,
|
||||
pos: spec.pos,
|
||||
size: spec.size,
|
||||
title_mode: spec.title_mode
|
||||
}) as unknown as LGraphNode
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const GAP = 12
|
||||
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
|
||||
@@ -27,7 +12,13 @@ describe('computeArrangement', () => {
|
||||
expect(computeArrangement([], 'vertical')).toEqual([])
|
||||
expect(
|
||||
computeArrangement(
|
||||
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
|
||||
[
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(1),
|
||||
pos: [0, 0],
|
||||
size: [100, 50]
|
||||
})
|
||||
],
|
||||
'grid'
|
||||
)
|
||||
).toEqual([])
|
||||
@@ -36,9 +27,21 @@ describe('computeArrangement', () => {
|
||||
describe('vertical', () => {
|
||||
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
|
||||
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
|
||||
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('a'),
|
||||
pos: [10, 100],
|
||||
size: [100, 50]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('b'),
|
||||
pos: [200, 0],
|
||||
size: [80, 30]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('c'),
|
||||
pos: [50, 200],
|
||||
size: [120, 40]
|
||||
})
|
||||
]
|
||||
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
|
||||
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
|
||||
@@ -56,14 +59,14 @@ describe('computeArrangement', () => {
|
||||
|
||||
it('omits the title-height contribution for NO_TITLE nodes', () => {
|
||||
const nodes = [
|
||||
makeNode({
|
||||
id: 1,
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(1),
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
}),
|
||||
makeNode({
|
||||
id: 2,
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(2),
|
||||
pos: [0, 200],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
@@ -74,22 +77,26 @@ describe('computeArrangement', () => {
|
||||
// 2: pos.y = 112.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
|
||||
{ nodeId: '1', position: { x: 0, y: 0 } },
|
||||
{ nodeId: '2', position: { x: 0, y: 100 + GAP } }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves heterogeneous heights when computing gaps', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
|
||||
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(1),
|
||||
pos: [0, 0],
|
||||
size: [100, 200]
|
||||
}),
|
||||
createMockLGraphNode({ id: toNodeId(2), pos: [0, 50], size: [100, 50] })
|
||||
]
|
||||
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
|
||||
// 2: pos.y = 212+30 = 242.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
{ nodeId: '1', position: { x: 0, y: 0 } },
|
||||
{ nodeId: '2', position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -97,9 +104,21 @@ describe('computeArrangement', () => {
|
||||
describe('horizontal', () => {
|
||||
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
|
||||
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
|
||||
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('a'),
|
||||
pos: [100, 50],
|
||||
size: [80, 40]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('b'),
|
||||
pos: [0, 200],
|
||||
size: [60, 30]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('c'),
|
||||
pos: [300, 80],
|
||||
size: [50, 50]
|
||||
})
|
||||
]
|
||||
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
|
||||
// Sort by X: b(0), a(100), c(300)
|
||||
@@ -119,10 +138,22 @@ describe('computeArrangement', () => {
|
||||
describe('grid', () => {
|
||||
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
|
||||
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
|
||||
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
|
||||
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
|
||||
createMockLGraphNode({ id: toNodeId(1), pos: [0, 0], size: [100, 50] }),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(2),
|
||||
pos: [200, 0],
|
||||
size: [80, 60]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(3),
|
||||
pos: [0, 100],
|
||||
size: [120, 40]
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(4),
|
||||
pos: [200, 100],
|
||||
size: [90, 30]
|
||||
})
|
||||
]
|
||||
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
|
||||
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
|
||||
@@ -131,18 +162,18 @@ describe('computeArrangement', () => {
|
||||
// pos.y = rowVisualTop + 30 (titleHeight).
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 132, y: 0 } },
|
||||
{ nodeId: 3, position: { x: 0, y: 102 } },
|
||||
{ nodeId: 4, position: { x: 132, y: 102 } }
|
||||
{ nodeId: '1', position: { x: 0, y: 0 } },
|
||||
{ nodeId: '2', position: { x: 132, y: 0 } },
|
||||
{ nodeId: '3', position: { x: 0, y: 102 } },
|
||||
{ nodeId: '4', position: { x: 132, y: 102 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
|
||||
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
|
||||
const nodes = Array.from({ length: 5 }, (_, i) =>
|
||||
makeNode({
|
||||
id: i + 1,
|
||||
createMockLGraphNode({
|
||||
id: toNodeId(i + 1),
|
||||
pos: [i * 50, i * 50],
|
||||
size: [40, 40]
|
||||
})
|
||||
@@ -164,11 +195,23 @@ describe('computeArrangement', () => {
|
||||
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
|
||||
const nodes = [
|
||||
// min y but large x: x+y = 1000
|
||||
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('minY'),
|
||||
pos: [1000, 0],
|
||||
size: [50, 50]
|
||||
}),
|
||||
// min x but large y: x+y = 1000
|
||||
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('minX'),
|
||||
pos: [0, 1000],
|
||||
size: [50, 50]
|
||||
}),
|
||||
// smallest x+y: 600
|
||||
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
|
||||
createMockLGraphNode({
|
||||
id: toNodeId('anchor'),
|
||||
pos: [300, 300],
|
||||
size: [50, 50]
|
||||
})
|
||||
]
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
@@ -1108,7 +1109,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
graph.add(host)
|
||||
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.id = 1
|
||||
interiorNode.id = toNodeId(1)
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interiorNode.addWidget(
|
||||
|
||||
@@ -34,6 +34,8 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import {
|
||||
collectAllNodes,
|
||||
@@ -54,6 +56,14 @@ type OriginalCallbacks = {
|
||||
|
||||
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
|
||||
|
||||
function getRemovedNodeExecutionId(graph: LGraph, nodeId: NodeId): string {
|
||||
if (!app.rootGraph) return String(nodeId)
|
||||
|
||||
return (
|
||||
getExecutionIdForNodeInGraph(app.rootGraph, graph, nodeId) ?? String(nodeId)
|
||||
)
|
||||
}
|
||||
|
||||
function installNodeHooks(node: LGraphNode): void {
|
||||
if (hookedNodes.has(node)) return
|
||||
hookedNodes.add(node)
|
||||
@@ -319,7 +329,7 @@ function scheduleAddedNodeScan(node: LGraphNode): void {
|
||||
|
||||
function handleNodeModeChange(
|
||||
localGraph: LGraph,
|
||||
nodeId: number,
|
||||
nodeId: NodeId,
|
||||
oldMode: number,
|
||||
newMode: number
|
||||
): void {
|
||||
@@ -416,9 +426,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
// "parentId:...:nodeId" path that matches how missing asset errors
|
||||
// are keyed; without this, removal falls back to the local ID and
|
||||
// misses subgraph entries.
|
||||
const execId = app.rootGraph
|
||||
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
|
||||
: String(node.id)
|
||||
const execId = getRemovedNodeExecutionId(graph, node.id)
|
||||
removeNodeErrors(node, execId)
|
||||
restoreNodeHooksRecursive(node)
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
@@ -429,7 +437,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
if (event.type === 'node:property:changed' && event.property === 'mode') {
|
||||
handleNodeModeChange(
|
||||
graph,
|
||||
event.nodeId as number,
|
||||
toNodeId(event.nodeId),
|
||||
event.oldValue as number,
|
||||
event.newValue as number
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('Node Reactivity', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: String(node.id),
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
await nextTick()
|
||||
@@ -116,7 +116,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
@@ -127,7 +127,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
// Verify initially linked
|
||||
@@ -155,7 +155,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const nodeData = vueNodeData.get(node.id)!
|
||||
|
||||
// Mimic what processedWidgets does in NodeWidgets.vue:
|
||||
// derive disabled from slotMetadata.linked
|
||||
@@ -204,7 +204,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
throw new Error('Expected SubgraphInput.connect to produce a link')
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(subgraph)
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
@@ -230,7 +230,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
@@ -242,7 +242,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const nodeData = vueNodeData.get(node.id)!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
@@ -278,7 +278,7 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeId = node.id
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeId = node.id
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
||||
|
||||
@@ -369,7 +369,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
graph.add(subgraphNodeB)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
|
||||
const nodeData = vueNodeData.get(subgraphNodeB.id)
|
||||
const mappedWidget = nodeData?.widgets?.[0]
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
@@ -406,7 +406,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -452,7 +452,7 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_input'
|
||||
)
|
||||
@@ -475,7 +475,7 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
@@ -714,12 +714,13 @@ describe('Pre-remove vueNodeData drain', () => {
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const id = node.id
|
||||
|
||||
expect(vueNodeData.has(String(node.id))).toBe(true)
|
||||
expect(vueNodeData.has(id)).toBe(true)
|
||||
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
|
||||
dataPresentInOnRemoved = vueNodeData.has(id)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
@@ -26,7 +27,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
@@ -46,7 +46,7 @@ import { app } from '@/scripts/app'
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
originNodeId?: string
|
||||
originNodeId?: NodeId
|
||||
originOutputName?: string
|
||||
type: string
|
||||
}
|
||||
@@ -129,10 +129,10 @@ export interface VueNodeData {
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: WorkflowNodeId): LGraphNode | undefined
|
||||
getNode(id: NodeId): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
@@ -335,14 +335,14 @@ function buildSlotMetadata(
|
||||
): Map<string, WidgetSlotMetadata> {
|
||||
const metadata = new Map<string, WidgetSlotMetadata>()
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: string | undefined
|
||||
let originNodeId: NodeId | undefined
|
||||
let originOutputName: string | undefined
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
|
||||
if (link && originNode) {
|
||||
originNodeId = String(link.origin_id)
|
||||
originNodeId = link.origin_id
|
||||
originOutputName = originNode.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
@@ -453,7 +453,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
const badges = node.badges
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
id: node.id,
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
@@ -480,12 +480,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
const nodeRefs = new Map<NodeId, LGraphNode>()
|
||||
|
||||
const refreshNodeSlots = (nodeId: string) => {
|
||||
const refreshNodeSlots = (nodeId: NodeId) => {
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
@@ -500,14 +500,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(String(id))
|
||||
const getNode = (id: NodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||
const currentNodes = new Set(graph._nodes.map((n) => n.id))
|
||||
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
@@ -519,7 +519,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
const id = String(node.id)
|
||||
const id = node.id
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
@@ -537,7 +537,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
const id = node.id
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
@@ -592,8 +592,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
const dropNodeReferences = (node: LGraphNode) => {
|
||||
const id = String(node.id)
|
||||
const dropNodeReferences = (id: NodeId) => {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
@@ -602,9 +601,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
const id = node.id
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
dropNodeReferences(id)
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
@@ -652,7 +654,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
dropNodeReferences(e.detail.node)
|
||||
dropNodeReferences(e.detail.node.id)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
@@ -663,7 +665,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = String(propertyEvent.nodeId)
|
||||
const nodeId = toNodeId(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
@@ -759,15 +761,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(String(slotErrorsEvent.nodeId))
|
||||
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
|
||||
}
|
||||
},
|
||||
'node:slot-label:changed': (slotLabelEvent) => {
|
||||
const nodeId = String(slotLabelEvent.nodeId)
|
||||
const nodeId = toNodeId(slotLabelEvent.nodeId)
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
if (!nodeRef) return
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
NodeId
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { getExtraOptionsForWidget } from '@/services/litegraphService'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
|
||||
import {
|
||||
@@ -50,7 +47,7 @@ export enum BadgeVariant {
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
|
||||
const hoveredWidget = ref<[string, NodeId | undefined]>()
|
||||
const hoveredWidget = ref<[string, SerializedNodeId | undefined]>()
|
||||
|
||||
/**
|
||||
* Toggle the node options popover
|
||||
@@ -70,7 +67,7 @@ export function toggleNodeOptions(event: Event) {
|
||||
export function showNodeOptions(
|
||||
event: MouseEvent,
|
||||
widgetName?: string,
|
||||
nodeId?: NodeId
|
||||
nodeId?: SerializedNodeId
|
||||
) {
|
||||
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
|
||||
if (nodeOptionsInstance?.show) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
import type { Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
@@ -45,7 +46,7 @@ const i18n = createI18n({
|
||||
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = id
|
||||
node.id = toNodeId(id)
|
||||
node.mode = mode
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -29,7 +30,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
id: node.id,
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
}))
|
||||
@@ -45,6 +46,11 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Seed existing links into the Layout Store (topology only)
|
||||
for (const link of activeGraph._links.values()) {
|
||||
if (
|
||||
link.origin_id === UNASSIGNED_NODE_ID ||
|
||||
link.target_id === UNASSIGNED_NODE_ID
|
||||
)
|
||||
continue
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
link.origin_id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { MIME_ASSET_INFO } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { zResultItem } from '@/schemas/apiSchema'
|
||||
import { parseAssetInfo } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
|
||||
type DragHandler = (e: DragEvent) => boolean
|
||||
@@ -14,14 +13,6 @@ interface DragAndDropOptions<T> {
|
||||
fileFilter?: (file: File) => boolean
|
||||
}
|
||||
|
||||
function parseAssetInfo(assetString?: string) {
|
||||
try {
|
||||
return zResultItem.safeParse(JSON.parse(assetString ?? '')).data
|
||||
} catch {
|
||||
// output was not parsable, allow fallthrough and return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds drag and drop file handling to a node
|
||||
* Will also resolve 'text/uri-list' to a file before passing
|
||||
@@ -67,7 +58,7 @@ export const useNodeDragAndDrop = <T>(
|
||||
await onDrop(files)
|
||||
return true
|
||||
}
|
||||
const asset = parseAssetInfo(e?.dataTransfer?.getData(MIME_ASSET_INFO))
|
||||
const asset = parseAssetInfo(e.dataTransfer!)
|
||||
if (asset?.filename && options.onResultItemDrop) {
|
||||
await options.onResultItemDrop(asset)
|
||||
return true
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -619,14 +620,15 @@ describe('useNodePricing', () => {
|
||||
|
||||
LiteGraph.vueNodesMode = true
|
||||
try {
|
||||
const revBefore = getNodeRevisionRef(node.id).value
|
||||
const nodeId = node.id
|
||||
const revBefore = getNodeRevisionRef(nodeId).value
|
||||
const tickBefore = pricingRevision.value
|
||||
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// VueNodes path bumps per-node ref and the global tick.
|
||||
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
|
||||
expect(getNodeRevisionRef(nodeId).value).toBeGreaterThan(revBefore)
|
||||
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
|
||||
} finally {
|
||||
LiteGraph.vueNodesMode = false
|
||||
@@ -658,7 +660,7 @@ describe('useNodePricing', () => {
|
||||
describe('getNodeRevisionRef', () => {
|
||||
it('should return a ref for a node ID', () => {
|
||||
const { getNodeRevisionRef } = useNodePricing()
|
||||
const ref = getNodeRevisionRef('node-1')
|
||||
const ref = getNodeRevisionRef(toNodeId('node-1'))
|
||||
|
||||
expect(ref).toBeDefined()
|
||||
expect(ref.value).toBe(0)
|
||||
@@ -666,25 +668,24 @@ describe('useNodePricing', () => {
|
||||
|
||||
it('should return the same ref for the same node ID', () => {
|
||||
const { getNodeRevisionRef } = useNodePricing()
|
||||
const ref1 = getNodeRevisionRef('node-same')
|
||||
const ref2 = getNodeRevisionRef('node-same')
|
||||
const ref1 = getNodeRevisionRef(toNodeId('node-same'))
|
||||
const ref2 = getNodeRevisionRef(toNodeId('node-same'))
|
||||
|
||||
expect(ref1).toBe(ref2)
|
||||
})
|
||||
|
||||
it('should return different refs for different node IDs', () => {
|
||||
const { getNodeRevisionRef } = useNodePricing()
|
||||
const ref1 = getNodeRevisionRef('node-a')
|
||||
const ref2 = getNodeRevisionRef('node-b')
|
||||
const ref1 = getNodeRevisionRef(toNodeId('node-a'))
|
||||
const ref2 = getNodeRevisionRef(toNodeId('node-b'))
|
||||
|
||||
expect(ref1).not.toBe(ref2)
|
||||
})
|
||||
|
||||
it('should handle both string and number node IDs', () => {
|
||||
const { getNodeRevisionRef } = useNodePricing()
|
||||
// Number ID gets stringified, so '123' and 123 should return the same ref
|
||||
const refFromNumber = getNodeRevisionRef(123)
|
||||
const refFromString = getNodeRevisionRef('123')
|
||||
const refFromNumber = getNodeRevisionRef(toNodeId(123))
|
||||
const refFromString = getNodeRevisionRef(toNodeId('123'))
|
||||
|
||||
expect(refFromNumber).toBe(refFromString)
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
WidgetDependency
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { Expression } from 'jsonata'
|
||||
import jsonata from 'jsonata'
|
||||
|
||||
@@ -452,18 +453,17 @@ const pricingTick = ref(0)
|
||||
// Per-node revision tracking for VueNodes mode (more efficient than global tick)
|
||||
// Uses plain Map with individual refs per node for fine-grained reactivity
|
||||
// Keys are stringified node IDs to handle both string and number ID types
|
||||
const nodeRevisions = new Map<string, Ref<number>>()
|
||||
const nodeRevisions = new Map<NodeId, Ref<number>>()
|
||||
|
||||
/**
|
||||
* Get or create a revision ref for a specific node.
|
||||
* Each node has its own independent ref, so updates to one won't trigger others.
|
||||
*/
|
||||
const getNodeRevisionRef = (nodeId: string | number): Ref<number> => {
|
||||
const key = String(nodeId)
|
||||
let rev = nodeRevisions.get(key)
|
||||
const getNodeRevisionRef = (nodeId: NodeId): Ref<number> => {
|
||||
let rev = nodeRevisions.get(nodeId)
|
||||
if (!rev) {
|
||||
rev = ref(0)
|
||||
nodeRevisions.set(key, rev)
|
||||
nodeRevisions.set(nodeId, rev)
|
||||
}
|
||||
return rev
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
|
||||
import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
@@ -58,7 +59,7 @@ function addInteriorNode(
|
||||
} = { id: 10 }
|
||||
): LGraphNode {
|
||||
const node = new LGraphNode('test')
|
||||
node.id = options.id
|
||||
node.id = toNodeId(options.id)
|
||||
if (options.previewMediaType) {
|
||||
node.previewMediaType = options.previewMediaType
|
||||
}
|
||||
@@ -69,7 +70,7 @@ function addInteriorNode(
|
||||
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
|
||||
const store = useNodeOutputStore()
|
||||
for (const nodeId of nodeIds) {
|
||||
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
||||
const locatorId = createNodeLocatorId(subgraphId, toNodeId(nodeId))
|
||||
store.nodeOutputs[locatorId] = {
|
||||
images: [{ filename: 'output.png' }]
|
||||
}
|
||||
@@ -82,7 +83,7 @@ function seedPreviewImages(
|
||||
) {
|
||||
const store = useNodeOutputStore()
|
||||
for (const { nodeId, urls } of entries) {
|
||||
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
||||
const locatorId = createNodeLocatorId(subgraphId, toNodeId(nodeId))
|
||||
store.nodePreviewImages[locatorId] = urls
|
||||
}
|
||||
}
|
||||
@@ -232,7 +233,9 @@ describe(usePromotedPreviews, () => {
|
||||
exposePreview(setup, '10')
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
seedPreviewImages(setup.subgraph.id, [
|
||||
{ nodeId: toNodeId(10), urls: [blobUrl] }
|
||||
])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -255,7 +258,9 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
seedPreviewImages(setup.subgraph.id, [
|
||||
{ nodeId: toNodeId(10), urls: [blobUrl] }
|
||||
])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import {
|
||||
appendNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
sourceNodeId: string
|
||||
sourceNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
@@ -41,7 +42,7 @@ export function usePromotedPreviews(
|
||||
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafSourceNodeId: NodeId,
|
||||
leafExecutionId: NodeExecutionId,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
@@ -49,6 +50,8 @@ export function usePromotedPreviews(
|
||||
leafHost.subgraph.id,
|
||||
leafSourceNodeId
|
||||
)
|
||||
if (!locatorId) return undefined
|
||||
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
const reactiveExecutionOutputs =
|
||||
@@ -89,7 +92,7 @@ export function usePromotedPreviews(
|
||||
function resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
currentHostLocator: string,
|
||||
sourceNodeId: string
|
||||
sourceNodeId: NodeId
|
||||
) {
|
||||
const currentHost = hostNodesByLocator.get(currentHostLocator)
|
||||
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
|
||||
@@ -123,10 +126,16 @@ export function usePromotedPreviews(
|
||||
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
|
||||
if (!interiorNode) return []
|
||||
|
||||
const leafExecutionId = appendNodeExecutionId(
|
||||
leafHostLocator,
|
||||
leaf.sourceNodeId
|
||||
)
|
||||
if (!leafExecutionId) return []
|
||||
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
|
||||
leafExecutionId,
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user