mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
19 Commits
vue-nodes/
...
bl-grahhhh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe63d89ef2 | ||
|
|
06469bebed | ||
|
|
0d4ecf801f | ||
|
|
aecbdced12 | ||
|
|
8b5c7ffb60 | ||
|
|
8586e684a6 | ||
|
|
cf42355a9f | ||
|
|
881ff67fe2 | ||
|
|
aa7f8912a7 | ||
|
|
76bfc9e678 | ||
|
|
0e44a4a354 | ||
|
|
9a89869517 | ||
|
|
551af4c0e0 | ||
|
|
76f21b9975 | ||
|
|
9f42f3dfb9 | ||
|
|
d7a7ed6d93 | ||
|
|
713ad134cf | ||
|
|
1328da0fbd | ||
|
|
6c7adf954a |
@@ -44,7 +44,6 @@
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:selected="nodeData.selected"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
@@ -79,6 +78,7 @@ import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -112,6 +112,7 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
@@ -189,6 +190,17 @@ const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
canvasStore.selectedItems
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
|
||||
@@ -595,6 +596,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
@@ -618,27 +620,48 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
nodePositions.set(id, nodePosition)
|
||||
nodeSizes.set(id, nodeSize)
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling with final positions
|
||||
const nodeBounds: Bounds = {
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y,
|
||||
width: nodeSize.width,
|
||||
height: nodeSize.height
|
||||
}
|
||||
spatialIndex.insert(id, nodeBounds, id)
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
|
||||
// Add node to layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
initializeVueNodeLayout
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
|
||||
@@ -33,12 +33,20 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Handle multi-select with Ctrl/Cmd key
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey
|
||||
|
||||
canvasStore.canvas.selectNode(node)
|
||||
if (isMultiSelect) {
|
||||
// Ctrl/Cmd+click -> toggle selection
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
} else {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
} else {
|
||||
// Regular click -> single select
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
@@ -47,9 +55,6 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
layoutMutations.bringNodeToFront(nodeData.id)
|
||||
}
|
||||
|
||||
// Ensure node selection state is set
|
||||
node.selected = true
|
||||
|
||||
// Update canvas selection tracking
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
@@ -972,5 +972,14 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Assets.UseAssetAPI',
|
||||
name: 'Use Asset API for model library',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Use new asset API instead of experiment endpoints for model browsing',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LinkRenderContext,
|
||||
LitegraphLinkAdapter
|
||||
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
@@ -5559,7 +5560,9 @@ export class LGraphCanvas
|
||||
const link = graph._links.get(link_id)
|
||||
if (!link) continue
|
||||
|
||||
const endPos = node.getInputPos(i)
|
||||
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(node, i, true)
|
||||
: node.getInputPos(i)
|
||||
|
||||
// find link info
|
||||
const start_node = graph.getNodeById(link.origin_id)
|
||||
@@ -5569,7 +5572,9 @@ export class LGraphCanvas
|
||||
const startPos: Point =
|
||||
outputId === -1
|
||||
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
|
||||
: start_node.getOutputPos(outputId)
|
||||
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(start_node, outputId, false)
|
||||
: start_node.getOutputPos(outputId)
|
||||
|
||||
const output = start_node.outputs[outputId]
|
||||
if (!output) continue
|
||||
|
||||
@@ -3833,33 +3833,12 @@ export class LGraphNode
|
||||
? this.getInputPos(slotIndex)
|
||||
: this.getOutputPos(slotIndex)
|
||||
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Vue-based slot dimensions
|
||||
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
|
||||
|
||||
if (slot.isWidgetInputSlot) {
|
||||
// Widget slots have a 20x20 clickable area centered at the position
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - 10
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = 20
|
||||
} else {
|
||||
// Regular slots have a 20x20 clickable area for the connector
|
||||
// but the full slot height for vertical spacing
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
|
||||
}
|
||||
} else {
|
||||
// Traditional LiteGraph dimensions
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
|
||||
@@ -24,26 +24,6 @@ import {
|
||||
} from './types/globalEnums'
|
||||
import { createUuidv4 } from './utils/uuid'
|
||||
|
||||
/**
|
||||
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
|
||||
* These values ensure both systems can independently calculate node, slot, and widget positions
|
||||
* to place them in identical locations.
|
||||
*
|
||||
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
|
||||
* for the positioning contract to work correctly.
|
||||
*/
|
||||
export const COMFY_VUE_NODE_DIMENSIONS = {
|
||||
spacing: {
|
||||
BETWEEN_SLOTS_AND_BODY: 8,
|
||||
BETWEEN_WIDGETS: 8
|
||||
},
|
||||
components: {
|
||||
HEADER_HEIGHT: 34, // 18 header + 16 padding
|
||||
SLOT_HEIGHT: 24,
|
||||
STANDARD_WIDGET_HEIGHT: 30
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
@@ -95,14 +75,6 @@ export class LiteGraphGlobal {
|
||||
WIDGET_SECONDARY_TEXT_COLOR = '#999'
|
||||
WIDGET_DISABLED_TEXT_COLOR = '#666'
|
||||
|
||||
/**
|
||||
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
|
||||
* These values ensure both systems can independently calculate node, slot, and widget positions
|
||||
* to place them in identical locations.
|
||||
*/
|
||||
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
|
||||
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
|
||||
|
||||
LINK_COLOR = '#9A9'
|
||||
EVENT_LINK_COLOR = '#A86'
|
||||
CONNECTING_LINK_COLOR = '#AFA'
|
||||
|
||||
@@ -104,7 +104,6 @@ export { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
export { LGraphCanvas } from './LGraphCanvas'
|
||||
export { LGraphGroup } from './LGraphGroup'
|
||||
export { LGraphNode, type NodeId } from './LGraphNode'
|
||||
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
|
||||
export { LLink } from './LLink'
|
||||
export { createBounds } from './measure'
|
||||
export { Reroute, type RerouteId } from './Reroute'
|
||||
|
||||
@@ -272,7 +272,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) {
|
||||
if (matchingIndex === -1) {
|
||||
console.debug(
|
||||
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
|
||||
this
|
||||
@@ -331,7 +331,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
* Used when bypassing nodes.
|
||||
* @param slot The output slot index on this node
|
||||
* @param type The type of the final target input (so type list matches are accurate)
|
||||
* @returns The index of the input slot on this node, otherwise `undefined`.
|
||||
* @returns The index of the input slot on this node, otherwise `-1`.
|
||||
*/
|
||||
#getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
const { inputs } = this
|
||||
@@ -352,15 +352,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
return slot
|
||||
}
|
||||
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
const exactMatch = inputs.findIndex((input) => input.type === type)
|
||||
if (exactMatch !== -1) return exactMatch
|
||||
|
||||
// Find first matching slot - prefer exact type
|
||||
return (
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
inputs.findIndex((input) => input.type === type) ??
|
||||
inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
)
|
||||
return inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "업데이트 확인"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "사용자 정의 노드 폴더 열기"
|
||||
"label": "커스텀 노드 폴더 열기"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "입력 폴더 열기"
|
||||
@@ -129,7 +129,7 @@
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_ExitSubgraph": {
|
||||
"label": "서브그래프 종료"
|
||||
"label": "서브그래프 나가기"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
@@ -138,7 +138,7 @@
|
||||
"label": "선택한 노드 그룹화"
|
||||
},
|
||||
"Comfy_Graph_UnpackSubgraph": {
|
||||
"label": "선택한 서브그래프 풀기"
|
||||
"label": "선택한 서브그래프 묶음 풀기"
|
||||
},
|
||||
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
|
||||
"label": "선택한 노드를 그룹 노드로 변환"
|
||||
@@ -171,13 +171,13 @@
|
||||
"label": "기본 워크플로 로드"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
|
||||
"label": "사용자 정의 노드 (베타)"
|
||||
"label": "커스텀 노드 (베타)"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
|
||||
"label": "커스텀 노드 (레거시)"
|
||||
"label": "커스텀 노드 (구버전)"
|
||||
},
|
||||
"Comfy_Manager_ShowLegacyManagerMenu": {
|
||||
"label": "매니저 메뉴 (레거시)"
|
||||
"label": "매니저 메뉴 (구버전)"
|
||||
},
|
||||
"Comfy_Manager_ShowMissingPacks": {
|
||||
"label": "누락된 팩 설치"
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"color": "색상",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"command": "명령",
|
||||
"commandProhibited": "명령 {command}은 금지되었습니다. 자세한 정보는 관리자에게 문의하십시오.",
|
||||
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
|
||||
"community": "커뮤니티",
|
||||
"completed": "완료됨",
|
||||
"confirm": "확인",
|
||||
@@ -326,7 +326,7 @@
|
||||
"findIssues": "문제 찾기",
|
||||
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
|
||||
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
|
||||
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상이 필요합니다.",
|
||||
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전이 필요합니다.",
|
||||
"goToNode": "노드로 이동",
|
||||
"help": "도움말",
|
||||
"icon": "아이콘",
|
||||
@@ -353,7 +353,7 @@
|
||||
"micPermissionDenied": "마이크 권한이 거부되었습니다",
|
||||
"migrate": "이전(migrate)",
|
||||
"missing": "누락됨",
|
||||
"moreWorkflows": "더 많은 워크플로우",
|
||||
"moreWorkflows": "더 많은 워크플로",
|
||||
"name": "이름",
|
||||
"newFolder": "새 폴더",
|
||||
"next": "다음",
|
||||
@@ -464,8 +464,8 @@
|
||||
"appPathLocationTooltip": "ComfyUI의 앱 에셋 디렉토리. ComfyUI 코드 및 에셋을 저장합니다.",
|
||||
"cannotWrite": "선택한 경로에 쓸 수 없습니다",
|
||||
"chooseInstallationLocation": "설치 위치 선택",
|
||||
"customNodes": "사용자 정의 노드",
|
||||
"customNodesDescription": "기존 ComfyUI 설치에서 사용자 정의 노드를 다시 설치합니다.",
|
||||
"customNodes": "커스텀 노드",
|
||||
"customNodesDescription": "기존 ComfyUI 설치에서 커스텀 노드를 다시 설치합니다.",
|
||||
"desktopAppSettings": "데스크탑 앱 설정",
|
||||
"desktopAppSettingsDescription": "ComfyUI가 데스크탑에서 어떻게 작동하는지 구성하세요. 이 설정은 나중에 변경할 수 있습니다.",
|
||||
"desktopSettings": "데스크탑 설정",
|
||||
@@ -489,7 +489,7 @@
|
||||
"helpImprove": "ComfyUI 개선에 도움을 주세요",
|
||||
"installLocation": "설치 위치",
|
||||
"installLocationDescription": "ComfyUI의 사용자 데이터 디렉토리를 선택하십시오. 선택한 위치에 Python 환경이 설치됩니다. 선택한 디스크에 충분한 공간(~15GB)이 남아 있는지 확인하십시오.",
|
||||
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 사용자 정의 노드\n",
|
||||
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 커스텀 노드\n",
|
||||
"insufficientFreeSpace": "공간이 부족합니다 - 최소한의 여유 공간",
|
||||
"isOneDrive": "OneDrive에 설치하면 문제가 발생할 수 있습니다. OneDrive가 아닌 위치에 설치하는 것을 강력히 권장합니다.",
|
||||
"manualConfiguration": {
|
||||
@@ -633,9 +633,9 @@
|
||||
"installationQueue": "설치 대기열",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
"latestVersion": "최신",
|
||||
"legacyManagerUI": "레거시 UI 사용",
|
||||
"legacyManagerUIDescription": "레거시 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
|
||||
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 레거시 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
|
||||
"legacyManagerUI": "구버전 매니저 UI 사용",
|
||||
"legacyManagerUIDescription": "구버전 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
|
||||
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 구버전 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
|
||||
"license": "라이선스",
|
||||
"loadingVersions": "버전 로딩 중...",
|
||||
"nightlyVersion": "최신 테스트 버전(nightly)",
|
||||
@@ -663,7 +663,7 @@
|
||||
"pending": "대기 중",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"title": "사용자 정의 노드 관리자",
|
||||
"title": "커스텀 노드 관리자",
|
||||
"totalNodes": "총 노드",
|
||||
"tryAgainLater": "나중에 다시 시도해 주세요.",
|
||||
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
|
||||
@@ -750,8 +750,8 @@
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Custom Nodes (Legacy)": "커스텀 노드(레거시)",
|
||||
"Custom Nodes Manager": "사용자 정의 노드 관리자",
|
||||
"Custom Nodes (Legacy)": "커스텀 노드(구버전)",
|
||||
"Custom Nodes Manager": "커스텀 노드 관리자",
|
||||
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
@@ -770,7 +770,7 @@
|
||||
"Load Default Workflow": "기본 워크플로 불러오기",
|
||||
"Manage group nodes": "그룹 노드 관리",
|
||||
"Manager": "매니저",
|
||||
"Manager Menu (Legacy)": "매니저 메뉴(레거시)",
|
||||
"Manager Menu (Legacy)": "매니저 메뉴(구버전)",
|
||||
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
|
||||
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
|
||||
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
|
||||
@@ -779,7 +779,7 @@
|
||||
"New": "새로 만들기",
|
||||
"Next Opened Workflow": "다음 열린 워크플로",
|
||||
"Open": "열기",
|
||||
"Open Custom Nodes Folder": "사용자 정의 노드 폴더 열기",
|
||||
"Open Custom Nodes Folder": "커스텀 노드 폴더 열기",
|
||||
"Open DevTools": "개발자 도구 열기",
|
||||
"Open Inputs Folder": "입력 폴더 열기",
|
||||
"Open Logs Folder": "로그 폴더 열기",
|
||||
@@ -814,13 +814,13 @@
|
||||
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
|
||||
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
|
||||
"Toggle Queue Sidebar": "대기열 사이드바 전환",
|
||||
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
|
||||
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
|
||||
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
|
||||
"Toggle Search Box": "검색 상자 전환",
|
||||
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
|
||||
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
|
||||
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
|
||||
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
|
||||
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
|
||||
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
|
||||
"Undo": "실행 취소",
|
||||
@@ -828,10 +828,10 @@
|
||||
"Unload Models": "모델 언로드",
|
||||
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
|
||||
"Unlock Canvas": "캔버스 잠금 해제",
|
||||
"Unpack the selected Subgraph": "선택한 서브그래프 풀기",
|
||||
"Unpack the selected Subgraph": "선택한 서브그래프 묶음 풀기",
|
||||
"View": "보기",
|
||||
"Workflow": "워크플로",
|
||||
"Workflows": "워크플로우",
|
||||
"Workflows": "워크플로",
|
||||
"Zoom In": "확대",
|
||||
"Zoom Out": "축소",
|
||||
"Zoom to fit": "화면에 맞추기"
|
||||
@@ -842,10 +842,10 @@
|
||||
"renderErrorState": "에러 상태 렌더링",
|
||||
"showGroups": "프레임/그룹 표시",
|
||||
"showLinks": "링크 표시",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
|
||||
"sideToolbar_queue": "sideToolbar.대기열",
|
||||
"sideToolbar_workflows": "sideToolbar.워크플로우"
|
||||
"sideToolbar_modelLibrary": "사이드툴바.모델 라이브러리",
|
||||
"sideToolbar_nodeLibrary": "사이드툴바.노드 라이브러리",
|
||||
"sideToolbar_queue": "사이드툴바.대기열",
|
||||
"sideToolbar_workflows": "사이드툴바.워크플로"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "다시 보지 않기",
|
||||
@@ -1011,7 +1011,7 @@
|
||||
"name": "DirectML 장치 번호"
|
||||
},
|
||||
"disable-all-custom-nodes": {
|
||||
"name": "모든 사용자 정의 노드 로드 비활성화."
|
||||
"name": "모든 커스텀 노드 로드 비활성화."
|
||||
},
|
||||
"disable-ipex-optimize": {
|
||||
"name": "IPEX 최적화 비활성화"
|
||||
@@ -1247,7 +1247,7 @@
|
||||
"Basics": "기본",
|
||||
"ComfyUI Examples": "ComfyUI 예시",
|
||||
"ControlNet": "컨트롤넷",
|
||||
"Custom Nodes": "사용자 정의 노드",
|
||||
"Custom Nodes": "커스텀 노드",
|
||||
"Flux": "FLUX",
|
||||
"Image": "이미지",
|
||||
"Image API": "이미지 API",
|
||||
@@ -1636,7 +1636,7 @@
|
||||
"versionMismatchWarning": {
|
||||
"dismiss": "닫기",
|
||||
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
|
||||
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상 버전을 필요로 합니다.",
|
||||
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전이 필요합니다.",
|
||||
"title": "버전 호환성 경고",
|
||||
"updateFrontend": "프론트엔드 업데이트"
|
||||
},
|
||||
|
||||
8
src/renderer/core/canvas/injectionKeys.ts
Normal file
8
src/renderer/core/canvas/injectionKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Injection key for providing selected node IDs to Vue node components.
|
||||
* Contains a reactive Set of selected node IDs (as strings).
|
||||
*/
|
||||
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
|
||||
Symbol('selectedNodeIds')
|
||||
@@ -9,9 +9,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
INodeSlot,
|
||||
Point,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
|
||||
@@ -79,21 +77,6 @@ export function calculateInputSlotPosFromSlot(
|
||||
const { pos } = input
|
||||
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
|
||||
|
||||
// Check if we should use Vue positioning
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
if (isWidgetInputSlot(input)) {
|
||||
// Widget slot - pass the slot object
|
||||
return calculateVueSlotPosition(context, true, input, -1)
|
||||
} else {
|
||||
// Regular slot - find its index in default vertical inputs
|
||||
const defaultVerticalInputs = getDefaultVerticalInputs(context)
|
||||
const slotIndex = defaultVerticalInputs.indexOf(input)
|
||||
if (slotIndex !== -1) {
|
||||
return calculateVueSlotPosition(context, true, input, slotIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = context.slotStartY || 0
|
||||
@@ -131,15 +114,6 @@ export function calculateOutputSlotPos(
|
||||
const outputPos = outputSlot.pos
|
||||
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
|
||||
|
||||
// Check if we should use Vue positioning
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
|
||||
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
|
||||
if (slotIndex !== -1) {
|
||||
return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = context.slotStartY || 0
|
||||
@@ -163,7 +137,7 @@ export function getSlotPosition(
|
||||
node: LGraphNode,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
): ReadOnlyPoint {
|
||||
): Point {
|
||||
// Try to get precise position from slot layout (DOM-registered)
|
||||
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
|
||||
const slotLayout = layoutStore.getSlotLayout(slotKey)
|
||||
@@ -195,8 +169,23 @@ export function getSlotPosition(
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
}
|
||||
|
||||
// Fallback to node's own methods if layout not available
|
||||
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
|
||||
// Fallback: calculate directly from node properties if layout not available
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: node.pos[0],
|
||||
nodeY: node.pos[1],
|
||||
nodeWidth: node.size[0],
|
||||
nodeHeight: node.size[1],
|
||||
collapsed: node.flags.collapsed || false,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
return isInput
|
||||
? calculateInputSlotPos(context, slotIndex)
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,66 +207,3 @@ function getDefaultVerticalOutputs(
|
||||
): INodeOutputSlot[] {
|
||||
return context.outputs.filter((slot) => !slot.pos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate slot position using Vue node dimensions.
|
||||
* This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
|
||||
* @param context Node context
|
||||
* @param isInput Whether this is an input slot (true) or output slot (false)
|
||||
* @param slot The slot object (for widget detection)
|
||||
* @param slotIndex The index of the slot in the appropriate array
|
||||
* @returns The [x, y] position of the slot center in graph coordinates
|
||||
*/
|
||||
function calculateVueSlotPosition(
|
||||
context: SlotPositionContext,
|
||||
isInput: boolean,
|
||||
slot: INodeSlot,
|
||||
slotIndex: number
|
||||
): Point {
|
||||
const { nodeX, nodeY, nodeWidth, widgets } = context
|
||||
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
|
||||
const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
|
||||
|
||||
let slotCenterY: number
|
||||
|
||||
// IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
|
||||
// The header is rendered ABOVE this position at negative Y coordinates
|
||||
// So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
|
||||
const headerDifference =
|
||||
dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
|
||||
// Widget input slot - calculate based on widget position
|
||||
// Count regular (non-widget) input slots
|
||||
const regularInputCount = getDefaultVerticalInputs(context).length
|
||||
|
||||
// Find widget index
|
||||
const widgetIndex =
|
||||
widgets?.findIndex(
|
||||
(w) => w.name === (slot as INodeInputSlot).widget?.name
|
||||
) ?? 0
|
||||
|
||||
// Y position relative to the node body top (not the header)
|
||||
slotCenterY =
|
||||
headerDifference +
|
||||
regularInputCount * dimensions.SLOT_HEIGHT +
|
||||
(regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
|
||||
widgetIndex *
|
||||
(dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
|
||||
dimensions.STANDARD_WIDGET_HEIGHT / 2
|
||||
} else {
|
||||
// Regular slot (input or output)
|
||||
// Slots start at the top of the body, but we need to account for Vue's larger header
|
||||
slotCenterY =
|
||||
headerDifference +
|
||||
slotIndex * dimensions.SLOT_HEIGHT +
|
||||
dimensions.SLOT_HEIGHT / 2
|
||||
}
|
||||
|
||||
// Calculate X position
|
||||
// Input slots: 10px from left edge (center of 20x20 connector)
|
||||
// Output slots: 10px from right edge (center of 20x20 connector)
|
||||
const slotCenterX = isInput ? 10 : nodeWidth - 10
|
||||
|
||||
return [nodeX + slotCenterX, nodeY + slotCenterY]
|
||||
}
|
||||
|
||||
52
src/renderer/core/layout/dom/canvasRectCache.ts
Normal file
52
src/renderer/core/layout/dom/canvasRectCache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Canvas Rect Cache (VueUse-based)
|
||||
*
|
||||
* Tracks the client-origin and size of the graph canvas container using
|
||||
* useElementBounding, and exposes a small API to read the rect and
|
||||
* subscribe to changes.
|
||||
*
|
||||
* We assume no document scrolling (body is overflow: hidden). Layout
|
||||
* changes are driven by window resize and container/splitter changes.
|
||||
*/
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
// Target container element (covers the canvas fully and shares its origin)
|
||||
const containerRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
// Bind bounding measurement once; element may be resolved later
|
||||
const { x, y, width, height, update } = useElementBounding(containerRef, {
|
||||
// Track layout changes from resize; scrolling is disabled globally
|
||||
windowResize: true,
|
||||
windowScroll: false,
|
||||
immediate: true
|
||||
})
|
||||
|
||||
// Listener registry for external subscribers
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function ensureContainer() {
|
||||
if (!containerRef.value) {
|
||||
containerRef.value = document.getElementById(
|
||||
'graph-canvas-container'
|
||||
) as HTMLElement | null
|
||||
// Force an immediate measurement once the element is resolved
|
||||
if (containerRef.value) update()
|
||||
}
|
||||
}
|
||||
|
||||
// Notify subscribers when the bounding rect changes
|
||||
watch([x, y, width, height], () => {
|
||||
if (listeners.size) listeners.forEach((cb) => cb())
|
||||
})
|
||||
|
||||
export function onCanvasRectChange(cb: () => void): () => void {
|
||||
ensureContainer()
|
||||
listeners.add(cb)
|
||||
return () => listeners.delete(cb)
|
||||
}
|
||||
|
||||
export function getCanvasClientOrigin() {
|
||||
ensureContainer()
|
||||
return { left: x.value || 0, top: y.value || 0 }
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getCanvasClientOrigin,
|
||||
onCanvasRectChange
|
||||
} from '@/renderer/core/layout/dom/canvasRectCache'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
|
||||
|
||||
@@ -52,7 +56,7 @@ const cleanupFunctions = new WeakMap<
|
||||
Ref<HTMLElement | null>,
|
||||
{
|
||||
stopWatcher?: WatchStopHandle
|
||||
handleResize?: () => void
|
||||
unsubscribeRectChange?: () => void
|
||||
}
|
||||
>()
|
||||
|
||||
@@ -90,6 +94,8 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
|
||||
if (!el || !transform?.screenToCanvas) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
// Normalize to canvas-relative screen coordinates (CSS pixels)
|
||||
const { left: canvasLeft, top: canvasTop } = getCanvasClientOrigin()
|
||||
|
||||
// Skip if bounds haven't changed significantly (within 0.5px)
|
||||
if (lastMeasuredBounds.value) {
|
||||
@@ -106,10 +112,10 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
|
||||
|
||||
lastMeasuredBounds.value = rect
|
||||
|
||||
// Center of the visual connector (dot) in screen coords
|
||||
// Center of the visual connector (dot) in canvas-relative screen coords
|
||||
const centerScreen = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2
|
||||
x: rect.left + rect.width / 2 - canvasLeft,
|
||||
y: rect.top + rect.height / 2 - canvasTop
|
||||
}
|
||||
const centerCanvas = transform.screenToCanvas(centerScreen)
|
||||
|
||||
@@ -192,12 +198,11 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
|
||||
const cleanup = cleanupFunctions.get(elRef) || {}
|
||||
cleanup.stopWatcher = stopWatcher
|
||||
|
||||
// Window resize - remeasure as viewport changed
|
||||
const handleResize = () => {
|
||||
// Subscribe to canvas rect changes (covers window resize and layout changes)
|
||||
const unsubscribe = onCanvasRectChange(() =>
|
||||
scheduleMeasurement(measureAndCacheOffset)
|
||||
}
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
cleanup.handleResize = handleResize
|
||||
)
|
||||
cleanup.unsubscribeRectChange = unsubscribe
|
||||
cleanupFunctions.set(elRef, cleanup)
|
||||
})
|
||||
|
||||
@@ -209,9 +214,7 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
|
||||
const cleanup = cleanupFunctions.get(elRef)
|
||||
if (cleanup) {
|
||||
if (cleanup.stopWatcher) cleanup.stopWatcher()
|
||||
if (cleanup.handleResize) {
|
||||
window.removeEventListener('resize', cleanup.handleResize)
|
||||
}
|
||||
if (cleanup.unsubscribeRectChange) cleanup.unsubscribeRectChange()
|
||||
cleanupFunctions.delete(elRef)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type Point,
|
||||
type RerouteId,
|
||||
type RerouteLayout,
|
||||
type Size,
|
||||
type SlotLayout
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
|
||||
@@ -49,7 +50,62 @@ const logger = log.getLogger('LayoutStore')
|
||||
// Constants
|
||||
const REROUTE_RADIUS = 8
|
||||
|
||||
// Utility functions
|
||||
function asRerouteId(id: string | number): RerouteId {
|
||||
return Number(id)
|
||||
}
|
||||
|
||||
function asLinkId(id: string | number): LinkId {
|
||||
return Number(id)
|
||||
}
|
||||
|
||||
interface NodeLayoutData {
|
||||
id: NodeId
|
||||
position: Point
|
||||
size: Size
|
||||
zIndex: number
|
||||
visible: boolean
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
interface LinkData {
|
||||
id: LinkId
|
||||
sourceNodeId: NodeId
|
||||
targetNodeId: NodeId
|
||||
sourceSlot: number
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
interface RerouteData {
|
||||
id: RerouteId
|
||||
position: Point
|
||||
parentId: LinkId
|
||||
linkIds: LinkId[]
|
||||
}
|
||||
|
||||
// Generic typed Y.Map interface
|
||||
interface TypedYMap<T> {
|
||||
get<K extends keyof T>(key: K): T[K] | undefined
|
||||
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
|
||||
}
|
||||
|
||||
class LayoutStoreImpl implements LayoutStore {
|
||||
private static readonly NODE_DEFAULTS: NodeLayoutData = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
private static readonly REROUTE_DEFAULTS: RerouteData = {
|
||||
id: 0,
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: 0,
|
||||
linkIds: []
|
||||
}
|
||||
|
||||
// Yjs document and shared data structures
|
||||
private ydoc = new Y.Doc()
|
||||
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
|
||||
@@ -127,6 +183,34 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private getNodeField<K extends keyof NodeLayoutData>(
|
||||
ynode: Y.Map<unknown>,
|
||||
field: K,
|
||||
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
|
||||
): NodeLayoutData[K] {
|
||||
const typedNode = ynode as TypedYMap<NodeLayoutData>
|
||||
const value = typedNode.get(field)
|
||||
return value ?? defaultValue
|
||||
}
|
||||
|
||||
private getLinkField<K extends keyof LinkData>(
|
||||
ylink: Y.Map<unknown>,
|
||||
field: K
|
||||
): LinkData[K] | undefined {
|
||||
const typedLink = ylink as TypedYMap<LinkData>
|
||||
return typedLink.get(field)
|
||||
}
|
||||
|
||||
private getRerouteField<K extends keyof RerouteData>(
|
||||
yreroute: Y.Map<unknown>,
|
||||
field: K,
|
||||
defaultValue: RerouteData[K] = LayoutStoreImpl.REROUTE_DEFAULTS[field]
|
||||
): RerouteData[K] {
|
||||
const typedReroute = yreroute as TypedYMap<RerouteData>
|
||||
const value = typedReroute.get(field)
|
||||
return value ?? defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a customRef for a node layout
|
||||
*/
|
||||
@@ -678,7 +762,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Check precise distance for candidates
|
||||
for (const rerouteKey of candidateRerouteKeys) {
|
||||
const rerouteId = Number(rerouteKey) as RerouteId // Convert string key back to numeric
|
||||
const rerouteId = asRerouteId(rerouteKey)
|
||||
const rerouteLayout = this.rerouteLayouts.get(rerouteId)
|
||||
if (rerouteLayout) {
|
||||
const dx = point.x - rerouteLayout.position.x
|
||||
@@ -723,7 +807,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
slots: this.slotSpatialIndex.query(bounds),
|
||||
reroutes: this.rerouteSpatialIndex
|
||||
.query(bounds)
|
||||
.map((key) => Number(key) as RerouteId) // Convert string keys to numeric
|
||||
.map((key) => asRerouteId(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,7 +986,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return
|
||||
}
|
||||
|
||||
const size = ynode.get('size') as { width: number; height: number }
|
||||
const size = this.getNodeField(ynode, 'size')
|
||||
const newBounds = {
|
||||
x: operation.position.x,
|
||||
y: operation.position.y,
|
||||
@@ -931,7 +1015,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) return
|
||||
|
||||
const position = ynode.get('position') as Point
|
||||
const position = this.getNodeField(ynode, 'position')
|
||||
const newBounds = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -1120,9 +1204,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
|
||||
const connectedLinks: LinkId[] = []
|
||||
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
|
||||
const linkId = Number(linkIdStr) as LinkId
|
||||
const sourceNodeId = linkData.get('sourceNodeId') as NodeId
|
||||
const targetNodeId = linkData.get('targetNodeId') as NodeId
|
||||
const linkId = asLinkId(linkIdStr)
|
||||
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
|
||||
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
|
||||
|
||||
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
|
||||
connectedLinks.push(linkId)
|
||||
@@ -1136,7 +1220,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
*/
|
||||
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
|
||||
if (change.action === 'delete') {
|
||||
const linkId = Number(linkIdStr) as LinkId
|
||||
const linkId = asLinkId(linkIdStr)
|
||||
this.cleanupLinkData(linkId)
|
||||
}
|
||||
// Link was added or updated - geometry will be computed separately
|
||||
@@ -1175,7 +1259,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
change: YEventChange,
|
||||
rerouteIdStr: string
|
||||
): void {
|
||||
const rerouteId = Number(rerouteIdStr) as RerouteId
|
||||
const rerouteId = asRerouteId(rerouteIdStr)
|
||||
|
||||
if (change.action === 'delete') {
|
||||
this.handleRerouteDelete(rerouteId)
|
||||
@@ -1199,7 +1283,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const rerouteData = this.yreroutes.get(String(rerouteId))
|
||||
if (!rerouteData) return
|
||||
|
||||
const position = rerouteData.get('position') as Point
|
||||
const position = this.getRerouteField(rerouteData, 'position')
|
||||
if (!position) return
|
||||
|
||||
const layout = this.createRerouteLayout(rerouteId, position)
|
||||
@@ -1263,12 +1347,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
|
||||
return {
|
||||
id: ynode.get('id') as string,
|
||||
position: ynode.get('position') as Point,
|
||||
size: ynode.get('size') as { width: number; height: number },
|
||||
zIndex: ynode.get('zIndex') as number,
|
||||
visible: ynode.get('visible') as boolean,
|
||||
bounds: ynode.get('bounds') as Bounds
|
||||
id: this.getNodeField(ynode, 'id'),
|
||||
position: this.getNodeField(ynode, 'position'),
|
||||
size: this.getNodeField(ynode, 'size'),
|
||||
zIndex: this.getNodeField(ynode, 'zIndex'),
|
||||
visible: this.getNodeField(ynode, 'visible'),
|
||||
bounds: this.getNodeField(ynode, 'bounds')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ export const useTransformState = () => {
|
||||
|
||||
// Computed transform string for CSS
|
||||
const transformStyle = computed(() => ({
|
||||
// Match LiteGraph DragAndScale.toCanvasContext():
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
@@ -103,15 +107,15 @@ export const useTransformState = () => {
|
||||
* Applies the same transform that LiteGraph uses for rendering.
|
||||
* Essential for positioning Vue components to align with canvas elements.
|
||||
*
|
||||
* Formula: screen = canvas * scale + offset
|
||||
* Formula: screen = (canvas + offset) * scale
|
||||
*
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
return {
|
||||
x: point.x * camera.z + camera.x,
|
||||
y: point.y * camera.z + camera.y
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +125,15 @@ export const useTransformState = () => {
|
||||
* Inverse of canvasToScreen. Useful for hit testing and converting
|
||||
* mouse events back to canvas space.
|
||||
*
|
||||
* Formula: canvas = (screen - offset) / scale
|
||||
* Formula: canvas = screen / scale - offset
|
||||
*
|
||||
* @param point - Point in screen coordinate system
|
||||
* @returns Point in canvas coordinate system
|
||||
*/
|
||||
const screenToCanvas = (point: Point): Point => {
|
||||
return {
|
||||
x: (point.x - camera.x) / camera.z,
|
||||
y: (point.y - camera.y) / camera.z
|
||||
x: point.x / camera.z - camera.x,
|
||||
y: point.y / camera.z - camera.y
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg"
|
||||
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
@@ -10,9 +10,6 @@
|
||||
'lg-slot--dot-only': dotOnly,
|
||||
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
|
||||
}"
|
||||
:style="{
|
||||
height: slotHeight + 'px'
|
||||
}"
|
||||
>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
@@ -43,11 +40,7 @@ import {
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import {
|
||||
COMFY_VUE_NODE_DIMENSIONS,
|
||||
INodeSlot,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
// DOM-based slot registration for arbitrary positioning
|
||||
import {
|
||||
type TransformState,
|
||||
@@ -82,9 +75,6 @@ onErrorCaptured((error) => {
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Get slot height from litegraph constants
|
||||
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
|
||||
|
||||
const transformState = inject<TransformState | undefined>(
|
||||
'transformState',
|
||||
undefined
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
'bg-white dark-theme:bg-[#15161A]',
|
||||
'min-w-[445px]',
|
||||
'lg-node absolute border border-solid rounded-2xl',
|
||||
'outline outline-transparent outline-2 hover:outline-black dark-theme:hover:outline-white',
|
||||
'outline outline-transparent outline-2',
|
||||
{
|
||||
'border-blue-500 ring-2 ring-blue-300': selected,
|
||||
'border-[#e1ded5] dark-theme:border-[#292A30]': !selected,
|
||||
'outline-black dark-theme:outline-white': isSelected
|
||||
},
|
||||
{
|
||||
'border-blue-500 ring-2 ring-blue-300': isSelected,
|
||||
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
|
||||
'animate-pulse': executing,
|
||||
'opacity-50': nodeData.mode === 4,
|
||||
'border-red-500 bg-red-50': error,
|
||||
@@ -107,12 +110,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
|
||||
// Import the VueNodeData type
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -129,7 +133,6 @@ interface LGraphNodeProps {
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
readonly?: boolean
|
||||
selected?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
@@ -150,6 +153,19 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||
if (!selectedNodeIds) {
|
||||
throw new Error(
|
||||
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
|
||||
)
|
||||
}
|
||||
|
||||
// Computed selection state - only this node re-evaluates when its selection changes
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(props.nodeData.id)
|
||||
})
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const zoomRef = toRef(() => props.zoomLevel ?? 1)
|
||||
const {
|
||||
@@ -193,7 +209,7 @@ const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
|
||||
// Watch for external changes to the collapsed state
|
||||
watch(
|
||||
() => props.nodeData.flags?.collapsed,
|
||||
(newCollapsed) => {
|
||||
(newCollapsed: boolean | undefined) => {
|
||||
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
||||
isCollapsed.value = newCollapsed
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg"
|
||||
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
@@ -11,9 +11,6 @@
|
||||
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
|
||||
'justify-center': dotOnly
|
||||
}"
|
||||
:style="{
|
||||
height: slotHeight + 'px'
|
||||
}"
|
||||
>
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
@@ -45,7 +42,6 @@ import {
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
|
||||
// DOM-based slot registration for arbitrary positioning
|
||||
import {
|
||||
type TransformState,
|
||||
@@ -81,9 +77,6 @@ onErrorCaptured((error) => {
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Get slot height from litegraph constants
|
||||
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
|
||||
|
||||
const transformState = inject<TransformState | undefined>(
|
||||
'transformState',
|
||||
undefined
|
||||
|
||||
@@ -57,7 +57,9 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
disabled: false,
|
||||
optionLabel: 'label',
|
||||
optionValue: 'value'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -65,8 +67,7 @@ const emit = defineEmits<Emits>()
|
||||
// handle both string/number arrays and object arrays with PrimeVue compatibility
|
||||
const getOptionValue = (option: T, index: number): string => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
// Use PrimeVue optionValue prop if provided, otherwise fallback to common fields
|
||||
const valueField = props.optionValue ?? 'value'
|
||||
const valueField = props.optionValue
|
||||
const value =
|
||||
(option as any)[valueField] ??
|
||||
(option as any).value ??
|
||||
@@ -81,8 +82,7 @@ const getOptionValue = (option: T, index: number): string => {
|
||||
// for display with PrimeVue compatibility
|
||||
const getOptionLabel = (option: T): string => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
// Use PrimeVue optionLabel prop if provided, otherwise fallback to common fields
|
||||
const labelField = props.optionLabel ?? 'label'
|
||||
const labelField = props.optionLabel
|
||||
return (
|
||||
(option as any)[labelField] ??
|
||||
(option as any).label ??
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
|
||||
import { noop } from 'es-toolkit'
|
||||
|
||||
import { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
defineProps<{
|
||||
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
|
||||
}>()
|
||||
|
||||
// Get widget height from litegraph constants
|
||||
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
:style="{ height: widgetHeight + 'px' }"
|
||||
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
|
||||
>
|
||||
<p
|
||||
v-if="widget.name"
|
||||
@@ -21,7 +18,12 @@ const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
|
||||
>
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
<div class="w-75">
|
||||
<div
|
||||
class="w-75"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,6 +467,7 @@ const zSettings = z.object({
|
||||
'Comfy.Minimap.RenderErrorState': z.boolean(),
|
||||
'Comfy.Canvas.NavigationMode': z.string(),
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
'Comfy-Desktop.WindowStyle': z.string(),
|
||||
|
||||
@@ -682,7 +682,8 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
const folderBlacklist = ['configs', 'custom_nodes']
|
||||
return (await res.json()).filter(
|
||||
(folder: string) => !folderBlacklist.includes(folder)
|
||||
(folder: { name: string; folders: string[] }) =>
|
||||
!folderBlacklist.includes(folder.name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1019,7 +1020,13 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
|
||||
async getFolderPaths(): Promise<Record<string, string[]>> {
|
||||
return (await axios.get(this.internalURL('/folder_paths'))).data
|
||||
const response = await axios
|
||||
.get(this.internalURL('/folder_paths'))
|
||||
.catch(() => null)
|
||||
if (!response) {
|
||||
return {} // Fallback: no filesystem paths known when API unavailable
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* Frees memory by unloading models and optionally freeing execution cache
|
||||
|
||||
151
src/services/assetService.ts
Normal file
151
src/services/assetService.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const MODELS_TAG = 'models'
|
||||
const MISSING_TAG = 'missing'
|
||||
|
||||
// Types for asset API responses
|
||||
interface AssetResponse {
|
||||
assets?: Asset[]
|
||||
total?: number
|
||||
has_more?: boolean
|
||||
}
|
||||
|
||||
interface Asset {
|
||||
id: string
|
||||
name: string
|
||||
tags: string[]
|
||||
size: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for validating asset structure
|
||||
*/
|
||||
function isValidAsset(asset: unknown): asset is Asset {
|
||||
return (
|
||||
asset !== null &&
|
||||
typeof asset === 'object' &&
|
||||
'id' in asset &&
|
||||
'name' in asset &&
|
||||
'tags' in asset &&
|
||||
Array.isArray((asset as Asset).tags)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates predicate for filtering assets by folder and excluding missing ones
|
||||
*/
|
||||
function createAssetFolderFilter(folder?: string) {
|
||||
return (asset: unknown): asset is Asset => {
|
||||
if (!isValidAsset(asset) || asset.tags.includes(MISSING_TAG)) {
|
||||
return false
|
||||
}
|
||||
if (folder && !asset.tags.includes(folder)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates predicate for filtering folder assets (requires name)
|
||||
*/
|
||||
function createFolderAssetFilter(folder: string) {
|
||||
return (asset: unknown): asset is Asset => {
|
||||
if (!isValidAsset(asset) || !asset.name) {
|
||||
return false
|
||||
}
|
||||
return asset.tags.includes(folder) && !asset.tags.includes(MISSING_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private service for asset-related network requests
|
||||
* Not exposed globally - used internally by ComfyApi
|
||||
*/
|
||||
function createAssetService() {
|
||||
/**
|
||||
* Handles API response with consistent error handling
|
||||
*/
|
||||
async function handleAssetRequest(
|
||||
url: string,
|
||||
context: string
|
||||
): Promise<AssetResponse> {
|
||||
const res = await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
/**
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
* Logic:
|
||||
* 1. Extract directory names directly from asset tags
|
||||
* 2. Filter out blacklisted directories
|
||||
* 3. Return alphabetically sorted directories with assets
|
||||
*
|
||||
* @returns The list of model folder keys
|
||||
*/
|
||||
async function getAssetModelFolders(): Promise<
|
||||
{ name: string; folders: string[] }[]
|
||||
> {
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
|
||||
'model folders'
|
||||
)
|
||||
|
||||
// Blacklist directories we don't want to show
|
||||
const blacklistedDirectories = ['configs']
|
||||
|
||||
// Extract directory names from assets that actually exist, exclude missing assets
|
||||
const discoveredFolders = new Set<string>()
|
||||
if (data?.assets) {
|
||||
const directoryTags = data.assets
|
||||
.filter(createAssetFolderFilter())
|
||||
.flatMap((asset) => asset.tags)
|
||||
.filter(
|
||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
|
||||
)
|
||||
|
||||
for (const tag of directoryTags) {
|
||||
discoveredFolders.add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Return only discovered folders in alphabetical order
|
||||
const sortedFolders = Array.from(discoveredFolders).sort()
|
||||
return sortedFolders.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of models in the specified folder from the asset API
|
||||
* @param folder The folder to list models from, such as 'checkpoints'
|
||||
* @returns The list of model filenames within the specified folder
|
||||
*/
|
||||
async function getAssetModels(
|
||||
folder: string
|
||||
): Promise<{ name: string; pathIndex: number }[]> {
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
|
||||
`models for ${folder}`
|
||||
)
|
||||
|
||||
return data?.assets
|
||||
? data.assets.filter(createFolderAssetFilter(folder)).map((asset) => ({
|
||||
name: asset.name,
|
||||
pathIndex: 0
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels
|
||||
}
|
||||
}
|
||||
|
||||
export const assetService = createAssetService()
|
||||
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { assetService } from '@/services/assetService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
|
||||
function _findInMetadata(metadata: any, ...keys: string[]): string | null {
|
||||
@@ -153,7 +155,12 @@ export class ModelFolder {
|
||||
models: Record<string, ComfyModelDef> = {}
|
||||
state: ResourceState = ResourceState.Uninitialized
|
||||
|
||||
constructor(public directory: string) {}
|
||||
constructor(
|
||||
public directory: string,
|
||||
private getModelsFunc: (
|
||||
folder: string
|
||||
) => Promise<{ name: string; pathIndex: number }[]>
|
||||
) {}
|
||||
|
||||
get key(): string {
|
||||
return this.directory + '/'
|
||||
@@ -167,7 +174,7 @@ export class ModelFolder {
|
||||
return this
|
||||
}
|
||||
this.state = ResourceState.Loading
|
||||
const models = await api.getModels(this.directory)
|
||||
const models = await this.getModelsFunc(this.directory)
|
||||
for (const model of models) {
|
||||
this.models[`${model.pathIndex}/${model.name}`] = new ComfyModelDef(
|
||||
model.name,
|
||||
@@ -182,6 +189,7 @@ export class ModelFolder {
|
||||
|
||||
/** Model store handler, wraps individual per-folder model stores */
|
||||
export const useModelStore = defineStore('models', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const modelFolderNames = ref<string[]>([])
|
||||
const modelFolderByName = ref<Record<string, ModelFolder>>({})
|
||||
const modelFolders = computed<ModelFolder[]>(() =>
|
||||
@@ -197,11 +205,22 @@ export const useModelStore = defineStore('models', () => {
|
||||
* Loads the model folders from the server
|
||||
*/
|
||||
async function loadModelFolders() {
|
||||
const resData = await api.getModelFolders()
|
||||
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
|
||||
const resData = useAssetAPI
|
||||
? await assetService.getAssetModelFolders()
|
||||
: await api.getModelFolders()
|
||||
modelFolderNames.value = resData.map((folder) => folder.name)
|
||||
modelFolderByName.value = {}
|
||||
for (const folderName of modelFolderNames.value) {
|
||||
modelFolderByName.value[folderName] = new ModelFolder(folderName)
|
||||
const getModelsFunc = useAssetAPI
|
||||
? (folder: string) => assetService.getAssetModels(folder)
|
||||
: (folder: string) => api.getModels(folder)
|
||||
|
||||
modelFolderByName.value[folderName] = new ModelFolder(
|
||||
folderName,
|
||||
getModelsFunc
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
tests-ui/tests/api.folderPaths.test.ts
Normal file
30
tests-ui/tests/api.folderPaths.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
describe('getFolderPaths', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('returns legacy API response when available', async () => {
|
||||
const mockResponse = { checkpoints: ['/test/checkpoints'] }
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockResponse })
|
||||
|
||||
const result = await api.getFolderPaths()
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('returns empty object when legacy API unavailable (dynamic discovery)', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(new Error())
|
||||
|
||||
const result = await api.getFolderPaths()
|
||||
|
||||
// With dynamic discovery, we don't pre-generate directories when API is unavailable
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -121,24 +121,24 @@ describe('useTransformState', () => {
|
||||
const canvasPoint = { x: 10, y: 20 }
|
||||
const screenPoint = canvasToScreen(canvasPoint)
|
||||
|
||||
// screen = canvas * scale + offset
|
||||
// x: 10 * 2 + 100 = 120
|
||||
// y: 20 * 2 + 50 = 90
|
||||
expect(screenPoint).toEqual({ x: 120, y: 90 })
|
||||
// screen = (canvas + offset) * scale
|
||||
// x: (10 + 100) * 2 = 220
|
||||
// y: (20 + 50) * 2 = 140
|
||||
expect(screenPoint).toEqual({ x: 220, y: 140 })
|
||||
})
|
||||
|
||||
it('should handle zero coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: 0, y: 0 })
|
||||
expect(screenPoint).toEqual({ x: 100, y: 50 })
|
||||
expect(screenPoint).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const { canvasToScreen } = transformState
|
||||
|
||||
const screenPoint = canvasToScreen({ x: -10, y: -20 })
|
||||
expect(screenPoint).toEqual({ x: 80, y: 10 })
|
||||
expect(screenPoint).toEqual({ x: 180, y: 60 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,12 +146,12 @@ describe('useTransformState', () => {
|
||||
it('should convert screen coordinates to canvas coordinates', () => {
|
||||
const { screenToCanvas } = transformState
|
||||
|
||||
const screenPoint = { x: 120, y: 90 }
|
||||
const screenPoint = { x: 220, y: 140 }
|
||||
const canvasPoint = screenToCanvas(screenPoint)
|
||||
|
||||
// canvas = (screen - offset) / scale
|
||||
// x: (120 - 100) / 2 = 10
|
||||
// y: (90 - 50) / 2 = 20
|
||||
// canvas = screen / scale - offset
|
||||
// x: 220 / 2 - 100 = 10
|
||||
// y: 140 / 2 - 50 = 20
|
||||
expect(canvasPoint).toEqual({ x: 10, y: 20 })
|
||||
})
|
||||
|
||||
@@ -183,11 +183,11 @@ describe('useTransformState', () => {
|
||||
const nodeSize = [200, 100]
|
||||
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
||||
|
||||
// Top-left: canvasToScreen(10, 20) = (120, 90)
|
||||
// Top-left: canvasToScreen(10, 20) = (220, 140)
|
||||
// Width: 200 * 2 = 400
|
||||
// Height: 100 * 2 = 200
|
||||
expect(bounds.x).toBe(120)
|
||||
expect(bounds.y).toBe(90)
|
||||
expect(bounds.x).toBe(220)
|
||||
expect(bounds.y).toBe(140)
|
||||
expect(bounds.width).toBe(400)
|
||||
expect(bounds.height).toBe(200)
|
||||
})
|
||||
@@ -288,14 +288,14 @@ describe('useTransformState', () => {
|
||||
// topLeft in screen: (-200, -120)
|
||||
// bottomRight in screen: (1200, 720)
|
||||
|
||||
// Convert to canvas coordinates:
|
||||
// topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
|
||||
// bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
|
||||
// Convert to canvas coordinates (canvas = screen / scale - offset):
|
||||
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
|
||||
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
|
||||
|
||||
expect(bounds.x).toBe(-150)
|
||||
expect(bounds.y).toBe(-85)
|
||||
expect(bounds.width).toBe(700) // 550 - (-150)
|
||||
expect(bounds.height).toBe(420) // 335 - (-85)
|
||||
expect(bounds.x).toBe(-200)
|
||||
expect(bounds.y).toBe(-110)
|
||||
expect(bounds.width).toBe(700) // 500 - (-200)
|
||||
expect(bounds.height).toBe(420) // 310 - (-110)
|
||||
})
|
||||
|
||||
it('should handle zero margin', () => {
|
||||
@@ -305,8 +305,8 @@ describe('useTransformState', () => {
|
||||
const bounds = getViewportBounds(viewport, 0)
|
||||
|
||||
// No margin, so viewport bounds are exact
|
||||
expect(bounds.x).toBe(-50) // (0 - 100) / 2
|
||||
expect(bounds.y).toBe(-25) // (0 - 50) / 2
|
||||
expect(bounds.x).toBe(-100) // 0 / 2 - 100
|
||||
expect(bounds.y).toBe(-50) // 0 / 2 - 50
|
||||
expect(bounds.width).toBe(500) // 1000 / 2
|
||||
expect(bounds.height).toBe(300) // 600 / 2
|
||||
})
|
||||
|
||||
238
tests-ui/tests/composables/graph/useNodeEventHandlers.test.ts
Normal file
238
tests-ui/tests/composables/graph/useNodeEventHandlers.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
VueNodeData,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
vi.mock('@/stores/graphStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockCanvas(): Pick<
|
||||
LGraphCanvas,
|
||||
'select' | 'deselect' | 'deselectAll'
|
||||
> {
|
||||
return {
|
||||
select: vi.fn(),
|
||||
deselect: vi.fn(),
|
||||
deselectAll: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNode(): Pick<LGraphNode, 'id' | 'selected' | 'flags'> {
|
||||
return {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
flags: { pinned: false }
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNodeManager(
|
||||
node: Pick<LGraphNode, 'id' | 'selected' | 'flags'>
|
||||
) {
|
||||
return {
|
||||
getNode: vi.fn().mockReturnValue(node) as ReturnType<
|
||||
typeof useGraphNodeManager
|
||||
>['getNode']
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCanvasStore(
|
||||
canvas: Pick<LGraphCanvas, 'select' | 'deselect' | 'deselectAll'>
|
||||
): Pick<
|
||||
ReturnType<typeof useCanvasStore>,
|
||||
'canvas' | 'selectedItems' | 'updateSelectedItems'
|
||||
> {
|
||||
return {
|
||||
canvas: canvas as LGraphCanvas,
|
||||
selectedItems: [],
|
||||
updateSelectedItems: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createMockLayoutMutations(): Pick<
|
||||
ReturnType<typeof useLayoutMutations>,
|
||||
'setSource' | 'bringNodeToFront'
|
||||
> {
|
||||
return {
|
||||
setSource: vi.fn(),
|
||||
bringNodeToFront: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodeEventHandlers', () => {
|
||||
let mockCanvas: ReturnType<typeof createMockCanvas>
|
||||
let mockNode: ReturnType<typeof createMockNode>
|
||||
let mockNodeManager: ReturnType<typeof createMockNodeManager>
|
||||
let mockCanvasStore: ReturnType<typeof createMockCanvasStore>
|
||||
let mockLayoutMutations: ReturnType<typeof createMockLayoutMutations>
|
||||
|
||||
const testNodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mockNode = createMockNode()
|
||||
mockCanvas = createMockCanvas()
|
||||
mockNodeManager = createMockNodeManager(mockNode)
|
||||
mockCanvasStore = createMockCanvasStore(mockCanvas)
|
||||
mockLayoutMutations = createMockLayoutMutations()
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
mockCanvasStore as ReturnType<typeof useCanvasStore>
|
||||
)
|
||||
vi.mocked(useLayoutMutations).mockReturnValue(
|
||||
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
|
||||
)
|
||||
})
|
||||
|
||||
describe('handleNodeSelect', () => {
|
||||
it('should select single node on regular click', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(event, testNodeData)
|
||||
|
||||
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should toggle selection on ctrl+click', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test selecting unselected node with ctrl
|
||||
mockNode.selected = false
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData)
|
||||
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('should deselect on ctrl+click of selected node', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test deselecting selected node with ctrl
|
||||
mockNode.selected = true
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData)
|
||||
|
||||
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle meta key (Cmd) on Mac', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.selected = false
|
||||
|
||||
const metaClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: false,
|
||||
metaKey: true
|
||||
})
|
||||
|
||||
handleNodeSelect(metaClickEvent, testNodeData)
|
||||
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bring node to front when not pinned', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.flags.pinned = false
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData)
|
||||
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
'node-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not bring pinned node to front', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.flags.pinned = true
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData)
|
||||
|
||||
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing canvas gracefully', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockCanvasStore.canvas = null
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
expect(() => {
|
||||
handleNodeSelect(event, testNodeData)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing node gracefully', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
vi.mocked(mockNodeManager.getNode).mockReturnValue(undefined)
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
const nodeData = {
|
||||
id: 'missing-node',
|
||||
title: 'Missing Node',
|
||||
type: 'test'
|
||||
} as any
|
||||
|
||||
expect(() => {
|
||||
handleNodeSelect(event, nodeData)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,17 +11,6 @@ LiteGraphGlobal {
|
||||
"CARD_SHAPE": 4,
|
||||
"CENTER": 5,
|
||||
"CIRCLE_SHAPE": 3,
|
||||
"COMFY_VUE_NODE_DIMENSIONS": {
|
||||
"components": {
|
||||
"HEADER_HEIGHT": 34,
|
||||
"SLOT_HEIGHT": 24,
|
||||
"STANDARD_WIDGET_HEIGHT": 30,
|
||||
},
|
||||
"spacing": {
|
||||
"BETWEEN_SLOTS_AND_BODY": 8,
|
||||
"BETWEEN_WIDGETS": 8,
|
||||
},
|
||||
},
|
||||
"CONNECTING_LINK_COLOR": "#AFA",
|
||||
"Classes": {
|
||||
"InputIndicators": [Function],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource, type NodeLayout } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
type LayoutChange,
|
||||
LayoutSource,
|
||||
type NodeLayout
|
||||
} from '@/renderer/core/layout/types'
|
||||
|
||||
describe('layoutStore CRDT operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -145,7 +149,7 @@ describe('layoutStore CRDT operations', () => {
|
||||
layoutStore.setActor('user-123')
|
||||
|
||||
// Track change notifications AFTER setting source/actor
|
||||
const changes: any[] = []
|
||||
const changes: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
changes.push(change)
|
||||
})
|
||||
|
||||
150
tests-ui/tests/services/assetService.test.ts
Normal file
150
tests-ui/tests/services/assetService.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { assetService } from '@/services/assetService'
|
||||
|
||||
// Test data constants
|
||||
const MOCK_ASSETS = {
|
||||
checkpoints: {
|
||||
id: 'uuid-1',
|
||||
name: 'model1.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
size: 123456
|
||||
},
|
||||
loras: {
|
||||
id: 'uuid-2',
|
||||
name: 'model2.safetensors',
|
||||
tags: ['models', 'loras'],
|
||||
size: 654321
|
||||
},
|
||||
vae: {
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae'],
|
||||
size: 789012
|
||||
}
|
||||
} as const
|
||||
|
||||
// Helper functions
|
||||
function mockApiResponse(assets: any[], options = {}) {
|
||||
const response = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false,
|
||||
...options
|
||||
}
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce(Response.json(response))
|
||||
return response
|
||||
}
|
||||
|
||||
function mockApiError(status: number, statusText = 'Error') {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce(
|
||||
new Response(null, { status, statusText })
|
||||
)
|
||||
}
|
||||
|
||||
describe('assetService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
})
|
||||
|
||||
describe('getAssetModelFolders', () => {
|
||||
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
|
||||
const assets = [
|
||||
{
|
||||
id: 'uuid-1',
|
||||
name: 'checkpoint1.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
size: 123456
|
||||
},
|
||||
{
|
||||
id: 'uuid-2',
|
||||
name: 'config.yaml',
|
||||
tags: ['models', 'configs'], // Blacklisted
|
||||
size: 654321
|
||||
},
|
||||
{
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae'],
|
||||
size: 789012
|
||||
}
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
const result = await assetService.getAssetModelFolders()
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models')
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const folderNames = result.map((f) => f.name)
|
||||
expect(folderNames).toEqual(['checkpoints', 'vae'])
|
||||
expect(folderNames).not.toContain('configs')
|
||||
})
|
||||
|
||||
it('should handle errors and empty responses', async () => {
|
||||
// Empty response
|
||||
mockApiResponse([])
|
||||
const emptyResult = await assetService.getAssetModelFolders()
|
||||
expect(emptyResult).toHaveLength(0)
|
||||
|
||||
// Network error
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
|
||||
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
|
||||
'Network error'
|
||||
)
|
||||
|
||||
// HTTP error
|
||||
mockApiError(500)
|
||||
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
|
||||
'Unable to load model folders: Server returned 500. Please try again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetModels', () => {
|
||||
it('should return filtered models for folder', async () => {
|
||||
const assets = [
|
||||
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
|
||||
{ ...MOCK_ASSETS.checkpoints, name: undefined }, // Invalid name
|
||||
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
|
||||
{
|
||||
id: 'uuid-4',
|
||||
name: 'missing-model.safetensors',
|
||||
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
|
||||
size: 654321
|
||||
}
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
const result = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models,checkpoints'
|
||||
)
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle errors and empty responses', async () => {
|
||||
// Empty response
|
||||
mockApiResponse([])
|
||||
const emptyResult = await assetService.getAssetModels('nonexistent')
|
||||
expect(emptyResult).toEqual([])
|
||||
|
||||
// Network error
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
|
||||
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
|
||||
'Network error'
|
||||
)
|
||||
|
||||
// HTTP error
|
||||
mockApiError(404)
|
||||
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
|
||||
'Unable to load models for checkpoints: Server returned 404. Please try again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { assetService } from '@/services/assetService'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
// Mock the api
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -13,7 +15,32 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function enableMocks() {
|
||||
// Mock the assetService
|
||||
vi.mock('@/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetModelFolders: vi.fn(),
|
||||
getAssetModels: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the settingStore
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn()
|
||||
}))
|
||||
|
||||
function enableMocks(useAssetAPI = false) {
|
||||
// Mock settingStore to return the useAssetAPI setting
|
||||
const mockSettingStore = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Assets.UseAssetAPI') {
|
||||
return useAssetAPI
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
||||
|
||||
// Mock experimental API - returns objects with name and folders properties
|
||||
vi.mocked(api.getModels).mockResolvedValue([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
@@ -23,6 +50,18 @@ function enableMocks() {
|
||||
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
])
|
||||
|
||||
// Mock asset API - also returns objects with name and folders properties
|
||||
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
|
||||
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
|
||||
{ name: 'vae', folders: ['/path/to/vae'] }
|
||||
])
|
||||
vi.mocked(assetService.getAssetModels).mockResolvedValue([
|
||||
{ name: 'sdxl.safetensors', pathIndex: 0 },
|
||||
{ name: 'sdv15.safetensors', pathIndex: 0 },
|
||||
{ name: 'noinfo.safetensors', pathIndex: 0 }
|
||||
])
|
||||
|
||||
vi.mocked(api.viewMetadata).mockImplementation((_, model) => {
|
||||
if (model === 'noinfo.safetensors') {
|
||||
return Promise.resolve({})
|
||||
@@ -46,26 +85,25 @@ describe('useModelStore', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
store = useModelStore()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should load models', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(folderStore).not.toBeNull()
|
||||
if (!folderStore) return
|
||||
expect(Object.keys(folderStore.models).length).toBe(3)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should load model metadata', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(folderStore).not.toBeNull()
|
||||
if (!folderStore) return
|
||||
const model = folderStore.models['0/sdxl.safetensors']
|
||||
expect(folderStore).toBeDefined()
|
||||
const model = folderStore!.models['0/sdxl.safetensors']
|
||||
await model.load()
|
||||
expect(model.title).toBe('Title of sdxl.safetensors')
|
||||
expect(model.architecture_id).toBe('stable-diffusion-xl-base-v1')
|
||||
@@ -79,11 +117,11 @@ describe('useModelStore', () => {
|
||||
|
||||
it('should handle no metadata', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
expect(folderStore).not.toBeNull()
|
||||
if (!folderStore) return
|
||||
const model = folderStore.models['0/noinfo.safetensors']
|
||||
expect(folderStore).toBeDefined()
|
||||
const model = folderStore!.models['0/noinfo.safetensors']
|
||||
await model.load()
|
||||
expect(model.file_name).toBe('noinfo.safetensors')
|
||||
expect(model.title).toBe('noinfo')
|
||||
@@ -95,6 +133,7 @@ describe('useModelStore', () => {
|
||||
|
||||
it('should cache model information', async () => {
|
||||
enableMocks()
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
expect(api.getModels).toHaveBeenCalledTimes(0)
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
@@ -102,4 +141,36 @@ describe('useModelStore', () => {
|
||||
await store.getLoadedModelFolder('checkpoints')
|
||||
expect(api.getModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('API switching functionality', () => {
|
||||
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
|
||||
enableMocks(false) // useAssetAPI = false
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
|
||||
enableMocks(true) // useAssetAPI = true
|
||||
store = useModelStore()
|
||||
await store.loadModelFolders()
|
||||
const folderStore = await store.getLoadedModelFolder('checkpoints')
|
||||
|
||||
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
|
||||
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
|
||||
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
|
||||
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
|
||||
expect(api.getModels).toHaveBeenCalledTimes(0)
|
||||
expect(folderStore).toBeDefined()
|
||||
expect(Object.keys(folderStore!.models)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('useSubgraphStore', () => {
|
||||
})
|
||||
it('should allow subgraphs to be edited', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
store.editBlueprint(store.typePrefix + 'test')
|
||||
await store.editBlueprint(store.typePrefix + 'test')
|
||||
//check active graph
|
||||
expect(comfyApp.loadGraphData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user