mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 13:59:28 +00:00
Compare commits
13 Commits
v1.33.2
...
live-previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4599368c | ||
|
|
c781421cad | ||
|
|
bdf6d4dea2 | ||
|
|
b8a796212c | ||
|
|
bc553f12be | ||
|
|
6bb35d46c1 | ||
|
|
68c38f0098 | ||
|
|
236247f05f | ||
|
|
87d6d18c57 | ||
|
|
87106ccb95 | ||
|
|
a20fb7d260 | ||
|
|
836cd7f9ba | ||
|
|
acd855601c |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder
|
||||
autofocus
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
@@ -28,6 +29,7 @@ const props = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||
@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
}
|
||||
},
|
||||
GeminiImageNode: {
|
||||
displayPrice: '$0.03 per 1K tokens'
|
||||
displayPrice: '~$0.039/Image (1K)'
|
||||
},
|
||||
GeminiImage2Node: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget) return 'Token-based'
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
if (resolution.includes('1K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('2K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('4K')) {
|
||||
return '~$0.24/Image'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: {
|
||||
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
|
||||
344
src/composables/useLivePreview.ts
Normal file
344
src/composables/useLivePreview.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface PropagationOptions {
|
||||
/**
|
||||
* Find output by name instead of index
|
||||
*/
|
||||
outputName?: string
|
||||
|
||||
/**
|
||||
* Explicitly specify output index (default: 0)
|
||||
*/
|
||||
outputIndex?: number
|
||||
|
||||
/**
|
||||
* Whether to call node.setOutputData (default: false)
|
||||
*/
|
||||
setOutputData?: boolean
|
||||
|
||||
/**
|
||||
* Whether to update target widget values (default: true)
|
||||
*/
|
||||
updateWidget?: boolean
|
||||
|
||||
/**
|
||||
* Whether to call widget.callback after updating (default: false)
|
||||
*/
|
||||
callWidgetCallback?: boolean
|
||||
|
||||
/**
|
||||
* Whether to call targetNode.onExecuted (default: false)
|
||||
*/
|
||||
callOnExecuted?: boolean
|
||||
|
||||
/**
|
||||
* Custom function to build the message for onExecuted
|
||||
*/
|
||||
messageBuilder?: (
|
||||
targetNode: LGraphNode,
|
||||
value: TWidgetValue,
|
||||
link: any
|
||||
) => any
|
||||
|
||||
/**
|
||||
* Custom handlers for specific node types
|
||||
* Return true if handled, false to continue with default behavior
|
||||
*/
|
||||
customHandlers?: Map<
|
||||
string,
|
||||
(node: LGraphNode, value: TWidgetValue, link: any) => boolean
|
||||
>
|
||||
|
||||
/**
|
||||
* Enable reentry protection (default: true)
|
||||
*/
|
||||
preventReentry?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculator function type for live preview nodes
|
||||
* Takes input values and returns the computed output value
|
||||
*/
|
||||
type LivePreviewCalculator = (inputValues: any[]) => TWidgetValue
|
||||
|
||||
/**
|
||||
* Configuration for setting up a live preview node
|
||||
*/
|
||||
interface LivePreviewNodeConfig {
|
||||
/**
|
||||
* The calculator function that computes output from inputs
|
||||
*/
|
||||
calculator: LivePreviewCalculator
|
||||
|
||||
/**
|
||||
* Optional output index (default: 0)
|
||||
*/
|
||||
outputIndex?: number
|
||||
|
||||
/**
|
||||
* Optional propagation options to use when propagating the result
|
||||
*/
|
||||
propagationOptions?: Omit<PropagationOptions, 'outputIndex' | 'setOutputData'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing live preview functionality in ComfyUI nodes
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a node extension:
|
||||
* const { setupLivePreviewNode, propagateLivePreview } = useLivePreview()
|
||||
*
|
||||
* // For computation nodes:
|
||||
* setupLivePreviewNode(node, {
|
||||
* calculator: (inputs) => {
|
||||
* const [a, b] = inputs
|
||||
* return a + b
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // For simple propagation:
|
||||
* propagateLivePreview(node, value, {
|
||||
* updateWidget: true,
|
||||
* callOnExecuted: true
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
const propagationFlags = new WeakMap<LGraphNode, Set<string>>()
|
||||
const nodeCalculators = new WeakMap<LGraphNode, LivePreviewNodeConfig>()
|
||||
|
||||
export function useLivePreview() {
|
||||
function getPropagationKey(outputIndex: number): string {
|
||||
return `propagating_${outputIndex}`
|
||||
}
|
||||
|
||||
function isNodePropagating(node: LGraphNode, outputIndex: number): boolean {
|
||||
const flags = propagationFlags.get(node)
|
||||
return flags?.has(getPropagationKey(outputIndex)) ?? false
|
||||
}
|
||||
|
||||
function setNodePropagating(
|
||||
node: LGraphNode,
|
||||
outputIndex: number,
|
||||
value: boolean
|
||||
): void {
|
||||
if (!propagationFlags.has(node)) {
|
||||
propagationFlags.set(node, new Set())
|
||||
}
|
||||
const flags = propagationFlags.get(node)!
|
||||
const key = getPropagationKey(outputIndex)
|
||||
|
||||
if (value) {
|
||||
flags.add(key)
|
||||
} else {
|
||||
flags.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function collectNodeInputValues(node: LGraphNode): any[] {
|
||||
const inputValues: any[] = []
|
||||
const graph = node.graph as LGraph
|
||||
|
||||
if (!graph || !node.inputs) {
|
||||
return inputValues
|
||||
}
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (input.link != null) {
|
||||
const link = graph.links[input.link]
|
||||
if (link) {
|
||||
const sourceNode = graph.getNodeById(link.origin_id)
|
||||
if (sourceNode && sourceNode.getOutputData) {
|
||||
const outputData = sourceNode.getOutputData(link.origin_slot)
|
||||
inputValues.push(outputData)
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
} else if (input.widget) {
|
||||
const widget = node.widgets?.find((w) => w.name === input.widget?.name)
|
||||
inputValues.push(widget?.value)
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return inputValues
|
||||
}
|
||||
|
||||
function triggerNodeRecalculation(node: LGraphNode): void {
|
||||
const config = nodeCalculators.get(node)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const inputValues = collectNodeInputValues(node)
|
||||
|
||||
const hasValidInputs = inputValues.some((v) => v !== undefined)
|
||||
if (!hasValidInputs) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = config.calculator(inputValues)
|
||||
if (result !== undefined) {
|
||||
propagateLivePreview(node, result, {
|
||||
outputIndex: config.outputIndex ?? 0,
|
||||
setOutputData: true,
|
||||
...config.propagationOptions
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error calculating live preview for node ${node.type}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function propagateLivePreview(
|
||||
sourceNode: LGraphNode,
|
||||
value: TWidgetValue,
|
||||
options: PropagationOptions = {}
|
||||
): void {
|
||||
const {
|
||||
outputName,
|
||||
outputIndex: explicitOutputIndex,
|
||||
setOutputData = false,
|
||||
updateWidget = true,
|
||||
callWidgetCallback = false,
|
||||
callOnExecuted = false,
|
||||
messageBuilder,
|
||||
customHandlers,
|
||||
preventReentry = true
|
||||
} = options
|
||||
|
||||
let outputIndex = explicitOutputIndex ?? 0
|
||||
|
||||
if (outputName && sourceNode.outputs) {
|
||||
const foundIndex = sourceNode.outputs.findIndex(
|
||||
(output) => output.name === outputName
|
||||
)
|
||||
if (foundIndex >= 0) {
|
||||
outputIndex = foundIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (preventReentry && isNodePropagating(sourceNode, outputIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (preventReentry) {
|
||||
setNodePropagating(sourceNode, outputIndex, true)
|
||||
}
|
||||
|
||||
try {
|
||||
if (setOutputData && sourceNode.setOutputData && value !== undefined) {
|
||||
sourceNode.setOutputData(outputIndex, value as any)
|
||||
}
|
||||
|
||||
const output = sourceNode.outputs?.[outputIndex]
|
||||
if (!output || !output.links || output.links.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const graph = sourceNode.graph as LGraph
|
||||
if (!graph) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = graph.links[linkId]
|
||||
if (!link) {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetNode = graph.getNodeById(link.target_id)
|
||||
if (!targetNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (customHandlers?.has(targetNode.type)) {
|
||||
const handler = customHandlers.get(targetNode.type)!
|
||||
const handled = handler(targetNode, value, link)
|
||||
if (handled) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (updateWidget) {
|
||||
const targetInput = targetNode.inputs?.[link.target_slot]
|
||||
if (targetInput?.widget) {
|
||||
const targetWidget = targetNode.widgets?.find(
|
||||
(w: IBaseWidget) => w.name === targetInput.widget?.name
|
||||
)
|
||||
|
||||
if (targetWidget) {
|
||||
targetWidget.value = value
|
||||
|
||||
if (callWidgetCallback && targetWidget.callback) {
|
||||
targetWidget.callback(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasCalculator = nodeCalculators.has(targetNode)
|
||||
|
||||
if (hasCalculator) {
|
||||
triggerNodeRecalculation(targetNode)
|
||||
continue
|
||||
}
|
||||
|
||||
if (callOnExecuted && targetNode.onExecuted) {
|
||||
const message = messageBuilder
|
||||
? messageBuilder(targetNode, value, link)
|
||||
: { text: [value] }
|
||||
|
||||
targetNode.onExecuted(message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (preventReentry) {
|
||||
setNodePropagating(sourceNode, outputIndex, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupLivePreviewNode(
|
||||
node: LGraphNode,
|
||||
config: LivePreviewNodeConfig
|
||||
): void {
|
||||
nodeCalculators.set(node, config)
|
||||
|
||||
const originalOnExecuted = node.onExecuted
|
||||
node.onExecuted = function (message: any) {
|
||||
if (originalOnExecuted) {
|
||||
originalOnExecuted.call(this, message)
|
||||
}
|
||||
|
||||
if (message.text && Array.isArray(message.text)) {
|
||||
const result = config.calculator(message.text)
|
||||
if (result !== undefined) {
|
||||
propagateLivePreview(this, result, {
|
||||
outputIndex: config.outputIndex ?? 0,
|
||||
setOutputData: true,
|
||||
...config.propagationOptions
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
propagateLivePreview,
|
||||
setupLivePreviewNode
|
||||
}
|
||||
}
|
||||
114
src/core/graph/widgets/dynamicWidgets.ts
Normal file
114
src/core/graph/widgets/dynamicWidgets.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
|
||||
function dynamicComboWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
appArg: ComfyApp,
|
||||
widgetName?: string
|
||||
) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
|
||||
const inputData = parseResult.data
|
||||
const options = Object.fromEntries(
|
||||
inputData[1].options.map(({ key, inputs }) => [key, inputs])
|
||||
)
|
||||
const subSpec: ComboInputSpec = [Object.keys(options), {}]
|
||||
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
|
||||
node,
|
||||
inputName,
|
||||
subSpec,
|
||||
appArg,
|
||||
widgetName
|
||||
)
|
||||
let currentDynamicNames: string[] = []
|
||||
const updateWidgets = (value?: string) => {
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
//TODO: Calculate intersection for widgets that persist across options
|
||||
//This would potentially allow links to be retained
|
||||
for (const name of currentDynamicNames) {
|
||||
const inputIndex = node.inputs.findIndex((input) => input.name === name)
|
||||
if (inputIndex !== -1) node.removeInput(inputIndex)
|
||||
const widgetIndex = node.widgets.findIndex(
|
||||
(widget) => widget.name === name
|
||||
)
|
||||
if (widgetIndex === -1) continue
|
||||
node.widgets[widgetIndex].value = undefined
|
||||
node.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
currentDynamicNames = []
|
||||
if (!newSpec) return
|
||||
|
||||
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
|
||||
const startingLength = node.widgets.length
|
||||
const inputInsertionPoint =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
const startingInputLength = node.inputs.length
|
||||
if (insertionPoint === 0)
|
||||
throw new Error("Dynamic widget doesn't exist on node")
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[newSpec.required, false],
|
||||
[newSpec.optional, true]
|
||||
]
|
||||
for (const [inputType, isOptional] of inputTypes)
|
||||
for (const name in inputType ?? {}) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputType![name], {
|
||||
name,
|
||||
isOptional
|
||||
})
|
||||
)
|
||||
currentDynamicNames.push(name)
|
||||
}
|
||||
|
||||
const addedWidgets = node.widgets.splice(startingLength)
|
||||
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
|
||||
if (inputInsertionPoint === 0) {
|
||||
if (
|
||||
addedWidgets.length === 0 &&
|
||||
node.inputs.length !== startingInputLength
|
||||
)
|
||||
//input is inputOnly, but lacks an insertion point
|
||||
throw new Error('Failed to find input socket for ' + widget.name)
|
||||
return
|
||||
}
|
||||
const addedInputs = node
|
||||
.spliceInputs(startingInputLength)
|
||||
.map((addedInput) => {
|
||||
const existingInput = node.inputs.findIndex(
|
||||
(existingInput) => addedInput.name === existingInput.name
|
||||
)
|
||||
return existingInput === -1
|
||||
? addedInput
|
||||
: node.spliceInputs(existingInput, 1)[0]
|
||||
})
|
||||
//assume existing inputs are in correct order
|
||||
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return widgetValue
|
||||
},
|
||||
set(value) {
|
||||
widgetValue = value
|
||||
updateWidgets(value)
|
||||
}
|
||||
})
|
||||
widget.value = widgetValue
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
@@ -14,6 +14,7 @@ import './matchType'
|
||||
import './nodeTemplates'
|
||||
import './noteNode'
|
||||
import './previewAny'
|
||||
import './stringOperations'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
import './saveMesh'
|
||||
|
||||
58
src/extensions/core/stringOperations.ts
Normal file
58
src/extensions/core/stringOperations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
|
||||
const { setupLivePreviewNode } = useLivePreview()
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.StringLength',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === 'StringLength') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.call(this)
|
||||
}
|
||||
|
||||
// Set up live preview with calculator
|
||||
setupLivePreviewNode(this, {
|
||||
calculator: (inputs) => {
|
||||
const inputString = inputs[0]
|
||||
if (inputString == null) return undefined
|
||||
return String(inputString).length
|
||||
},
|
||||
propagationOptions: {
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.StringConcatenate',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === 'StringConcatenate') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.call(this)
|
||||
}
|
||||
|
||||
// Set up live preview with calculator
|
||||
setupLivePreviewNode(this, {
|
||||
calculator: (inputs) => {
|
||||
const [string_a, string_b, delimiter] = inputs
|
||||
if (string_a == null && string_b == null) return undefined
|
||||
return [string_a ?? '', string_b ?? ''].join(delimiter || '')
|
||||
},
|
||||
propagationOptions: {
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -848,15 +848,13 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
const widgetsWithValue = this.widgets.filter(
|
||||
(w) => w.serialize !== false
|
||||
const widgetsWithValue = this.widgets
|
||||
.values()
|
||||
.filter((w) => w.serialize !== false)
|
||||
.filter((_w, idx) => idx < info.widgets_values!.length)
|
||||
widgetsWithValue.forEach(
|
||||
(widget, i) => (widget.value = info.widgets_values![i])
|
||||
)
|
||||
for (let i = 0; i < info.widgets_values.length; ++i) {
|
||||
const widget = widgetsWithValue[i]
|
||||
if (widget) {
|
||||
widget.value = info.widgets_values[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +882,7 @@ export class LGraphNode
|
||||
|
||||
// special case for when there were errors
|
||||
if (this.constructor === LGraphNode && this.last_serialization)
|
||||
return this.last_serialization
|
||||
return { ...this.last_serialization, mode: o.mode, pos: o.pos }
|
||||
|
||||
if (this.inputs)
|
||||
o.inputs = this.inputs.map((input) => inputAsSerialisable(input))
|
||||
@@ -1652,6 +1650,19 @@ export class LGraphNode
|
||||
this.onInputRemoved?.(slot, slot_info[0])
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
spliceInputs(
|
||||
startIndex: number,
|
||||
deleteCount = -1,
|
||||
...toAdd: INodeInputSlot[]
|
||||
): INodeInputSlot[] {
|
||||
if (deleteCount < 0) return this.inputs.splice(startIndex)
|
||||
const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd)
|
||||
this.inputs.slice(startIndex).forEach((input, index) => {
|
||||
const link = input.link && this.graph?.links?.get(input.link)
|
||||
if (link) link.target_slot = startIndex + index
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* computes the minimum size of a node according to its inputs and output slots
|
||||
|
||||
@@ -2076,6 +2076,35 @@
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Type to search...",
|
||||
"uploadModel": "Upload model",
|
||||
"uploadModelFromCivitai": "Upload a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Upload",
|
||||
"uploadingModel": "Uploading model...",
|
||||
"uploadSuccess": "Model uploaded successfully!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model uploaded! 🎉",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
@@ -2087,6 +2116,13 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUploadFailed": "Failed to upload asset. Please try again.",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
|
||||
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -92,6 +95,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function handleUploadClick() {
|
||||
// Will be implemented in the future commit
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('g.loading')
|
||||
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||
"
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | undefined
|
||||
metadata: AssetMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
}>()
|
||||
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue ?? null,
|
||||
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3"
|
||||
:status="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<UploadModelFooter
|
||||
:current-step="currentStep"
|
||||
:is-fetching-metadata="isFetchingMetadata"
|
||||
:is-uploading="isUploading"
|
||||
:can-fetch-metadata="canFetchMetadata"
|
||||
:can-upload-model="canUploadModel"
|
||||
:upload-status="uploadStatus"
|
||||
@back="goToPreviousStep"
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
await fetchMetadata()
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
const success = await uploadModel()
|
||||
if (success) {
|
||||
emit('upload-success')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'upload-model' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchModelTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-model-dialog {
|
||||
width: 90vw;
|
||||
max-width: 800px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.upload-model-dialog {
|
||||
width: auto;
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<span v-else />
|
||||
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isFetchingMetadata"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isUploading"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
size="md"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
isFetchingMetadata: boolean
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'fetchMetadata'): void
|
||||
(e: 'upload'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
</script>
|
||||
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-muted mb-0">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: AssetMetadata | null
|
||||
modelType: string | undefined
|
||||
}>()
|
||||
</script>
|
||||
50
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
50
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<UrlInput
|
||||
v-model="url"
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
:disable-validation="true"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const url = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
73
src/platform/assets/composables/useModelTypes.ts
Normal file
73
src/platform/assets/composables/useModelTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createSharedComposable, useAsyncState } from '@vueuse/core'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
gligen: 'GLIGEN'
|
||||
}
|
||||
|
||||
if (specialCases[folderName]) {
|
||||
return specialCases[folderName]
|
||||
}
|
||||
|
||||
return folderName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string // Display name
|
||||
value: string // Actual tag value
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing model types from the API
|
||||
* Uses shared state to ensure data is only fetched once
|
||||
*/
|
||||
export const useModelTypes = createSharedComposable(() => {
|
||||
const {
|
||||
state: modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
execute: fetchModelTypes
|
||||
} = useAsyncState(
|
||||
async (): Promise<ModelTypeOption[]> => {
|
||||
const response = await api.getModelFolders()
|
||||
return response.map((folder) => ({
|
||||
name: formatDisplayName(folder.name),
|
||||
value: folder.name
|
||||
}))
|
||||
},
|
||||
[] as ModelTypeOption[],
|
||||
{
|
||||
immediate: false,
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch model types:', err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
fetchModelTypes
|
||||
}
|
||||
})
|
||||
175
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
175
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
interface WizardData {
|
||||
url: string
|
||||
metadata: AssetMetadata | null
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||
const uploadError = ref('')
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(undefined)
|
||||
|
||||
// Clear error when URL changes
|
||||
watch(
|
||||
() => wizardData.value.url,
|
||||
() => {
|
||||
uploadError.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
// Validation
|
||||
const canFetchMetadata = computed(() => {
|
||||
return wizardData.value.url.trim().length > 0
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
return !!selectedModelType.value
|
||||
})
|
||||
|
||||
function isCivitaiUrl(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetadata() {
|
||||
if (!canFetchMetadata.value) return
|
||||
|
||||
if (!isCivitaiUrl(wizardData.value.url)) {
|
||||
uploadError.value = st(
|
||||
'assetBrowser.onlyCivitaiUrlsSupported',
|
||||
'Only Civitai URLs are supported'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingMetadata.value = true
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
|
||||
wizardData.value.metadata = metadata
|
||||
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
// Try to detect model type from tags
|
||||
const typeTag = metadata.tags.find((tag) =>
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
)
|
||||
if (typeTag) {
|
||||
selectedModelType.value = typeTag
|
||||
}
|
||||
}
|
||||
|
||||
currentStep.value = 2
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve metadata:', error)
|
||||
uploadError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: st(
|
||||
'assetBrowser.uploadModelFailedToRetrieveMetadata',
|
||||
'Failed to retrieve metadata. Please check the link and try again.'
|
||||
)
|
||||
currentStep.value = 1
|
||||
} finally {
|
||||
isFetchingMetadata.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel() {
|
||||
if (!canUploadModel.value) return
|
||||
|
||||
isUploading.value = true
|
||||
uploadStatus.value = 'uploading'
|
||||
|
||||
try {
|
||||
const tags = selectedModelType.value
|
||||
? ['models', selectedModelType.value]
|
||||
: ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
currentStep.value = 3
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to upload model'
|
||||
currentStep.value = 3
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value = currentStep.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
|
||||
// Computed
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
|
||||
// Actions
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,29 @@ const zModelFile = z.object({
|
||||
pathIndex: z.number()
|
||||
})
|
||||
|
||||
const zValidationError = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
field: z.string()
|
||||
})
|
||||
|
||||
const zValidationResult = z.object({
|
||||
is_valid: z.boolean(),
|
||||
errors: z.array(zValidationError).optional(),
|
||||
warnings: z.array(zValidationError).optional()
|
||||
})
|
||||
|
||||
const zAssetMetadata = z.object({
|
||||
content_length: z.number(),
|
||||
final_url: z.string(),
|
||||
content_type: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
preview_url: z.string().optional(),
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
// Filename validation schema
|
||||
export const assetFilenameSchema = z
|
||||
.string()
|
||||
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata,
|
||||
AssetResponse,
|
||||
ModelFile,
|
||||
ModelFolder
|
||||
@@ -10,6 +12,36 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Maps CivitAI validation error codes to localized error messages
|
||||
*/
|
||||
function getLocalizedErrorMessage(errorCode: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
|
||||
FORMAT_NOT_ALLOWED: st(
|
||||
'assetBrowser.errorFormatNotAllowed',
|
||||
'Format not allowed'
|
||||
),
|
||||
UNSAFE_PICKLE_SCAN: st(
|
||||
'assetBrowser.errorUnsafePickleScan',
|
||||
'Unsafe pickle scan'
|
||||
),
|
||||
UNSAFE_VIRUS_SCAN: st(
|
||||
'assetBrowser.errorUnsafeVirusScan',
|
||||
'Unsafe virus scan'
|
||||
),
|
||||
MODEL_TYPE_NOT_SUPPORTED: st(
|
||||
'assetBrowser.errorModelTypeNotSupported',
|
||||
'Model type not supported'
|
||||
)
|
||||
}
|
||||
return (
|
||||
errorMessages[errorCode] ||
|
||||
st('assetBrowser.errorUnknown', 'Unknown error') ||
|
||||
'Unknown error'
|
||||
)
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
@@ -249,6 +281,77 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata from a download URL without downloading the file
|
||||
*
|
||||
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
|
||||
* @returns Promise with metadata including content_length, final_url, filename, etc.
|
||||
* @throws Error if metadata retrieval fails
|
||||
*/
|
||||
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
const res = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
|
||||
)
|
||||
}
|
||||
|
||||
const data: AssetMetadata = await res.json()
|
||||
if (data.validation?.is_valid === false) {
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(
|
||||
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset by providing a URL to download from
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.url - HTTP/HTTPS URL to download from
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @param params.preview_id - Optional UUID for preview asset
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromUrl(params: {
|
||||
url: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
preview_id?: string
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -256,7 +359,9 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
deleteAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ import type {
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
@@ -83,12 +85,16 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
const { propagateLivePreview } = useLivePreview()
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
}
|
||||
|
||||
const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
@@ -113,6 +119,24 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value
|
||||
)
|
||||
|
||||
function propagateToDownstreamVue(
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
value: WidgetValue
|
||||
): void {
|
||||
const lgNode = nodeManager.value?.getNode(sourceNodeId)
|
||||
if (!lgNode || !value) {
|
||||
return
|
||||
}
|
||||
|
||||
propagateLivePreview(lgNode, value, {
|
||||
outputName: widgetName,
|
||||
updateWidget: true,
|
||||
callWidgetCallback: false,
|
||||
callOnExecuted: false
|
||||
})
|
||||
}
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
@@ -170,6 +194,10 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (widget.type !== 'asset') {
|
||||
widget.callback?.(value)
|
||||
}
|
||||
|
||||
if (nodeData?.id && nodeManager.value) {
|
||||
propagateToDownstreamVue(nodeData.id, widget.name, value)
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
@@ -60,6 +61,7 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (useCanvasStore().linearMode) return
|
||||
// Canvas is ready when this code runs; no defensive guards needed.
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
// Group updates by type, then flush via each config's handler
|
||||
|
||||
@@ -4,6 +4,10 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
|
||||
const { propagateLivePreview } = useLivePreview()
|
||||
|
||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
||||
|
||||
@@ -119,6 +123,22 @@ export const useStringWidget = () => {
|
||||
const defaultVal = inputSpec.default ?? ''
|
||||
const multiline = inputSpec.multiline
|
||||
|
||||
const propagateCallback = (value: WidgetValue) => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simple propagation: just send the value downstream
|
||||
// - Nodes with calculators will automatically recalculate
|
||||
// - Passive nodes (like PreviewAny) will receive onExecuted
|
||||
propagateLivePreview(node, value, {
|
||||
outputName: inputSpec.name,
|
||||
setOutputData: true,
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
})
|
||||
}
|
||||
|
||||
const widget = multiline
|
||||
? addMultilineWidget(node, inputSpec.name, {
|
||||
defaultVal,
|
||||
@@ -130,6 +150,23 @@ export const useStringWidget = () => {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
}
|
||||
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = function (value: WidgetValue) {
|
||||
if (originalCallback) {
|
||||
;(originalCallback as any).call(this, value)
|
||||
}
|
||||
|
||||
const input = node.inputs?.find(
|
||||
(input) => input.widget?.name === inputSpec.name
|
||||
)
|
||||
|
||||
if (input?.link) {
|
||||
return
|
||||
}
|
||||
|
||||
propagateCallback(value)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,18 @@ export const zComfyNodeDef = z.object({
|
||||
input_order: z.record(z.array(z.string())).optional()
|
||||
})
|
||||
|
||||
export const zDynamicComboInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICCOMBO_V3'),
|
||||
zComboInputOptions.extend({
|
||||
options: z.array(
|
||||
z.object({
|
||||
inputs: zComfyInputsSpec,
|
||||
key: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
@@ -296,5 +297,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
...dynamicWidgets
|
||||
}
|
||||
|
||||
@@ -304,11 +304,13 @@ export const useDialogService = () => {
|
||||
async function prompt({
|
||||
title,
|
||||
message,
|
||||
defaultValue = ''
|
||||
defaultValue = '',
|
||||
placeholder
|
||||
}: {
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
}): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogStore.showDialog({
|
||||
@@ -320,7 +322,8 @@ export const useDialogService = () => {
|
||||
defaultValue,
|
||||
onConfirm: (value: string) => {
|
||||
resolve(value)
|
||||
}
|
||||
},
|
||||
placeholder
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
|
||||
@@ -60,6 +60,10 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
import { useExtensionService } from './extensionService'
|
||||
|
||||
export interface HasInitialMinSize {
|
||||
_initialMinSize: { width: number; height: number }
|
||||
}
|
||||
|
||||
export const CONFIG = Symbol()
|
||||
export const GET_CONFIG = Symbol()
|
||||
|
||||
@@ -73,28 +77,184 @@ export const useLitegraphService = () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
function nodeKey(node: LGraphNode): string {
|
||||
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
|
||||
}
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
function addInputSocket(node: LGraphNode, inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
node.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
function setupStrokeStyles(node: LGraphNode) {
|
||||
node.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function. Implemented for use with dynamic widgets
|
||||
*/
|
||||
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
|
||||
addInputSocket(node, inputSpec)
|
||||
addInputWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
node,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
node.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
const castedNode = node as LGraphNode & HasInitialMinSize
|
||||
castedNode._initialMinSize.width = Math.max(
|
||||
castedNode._initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
castedNode._initialMinSize.height = Math.max(
|
||||
castedNode._initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
function addInputs(node: LGraphNode, inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
//@ts-expect-error was ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const nodeDefImpl = node.constructor.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs) addInputSocket(node, inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs) addInputWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name: type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
node.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
function setInitialSize(node: LGraphNode) {
|
||||
const s = node.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
node.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
const castedNode = node as LGraphNode & HasInitialMinSize
|
||||
s[0] = Math.max(castedNode._initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(castedNode._initialMinSize.height, s[1])
|
||||
node.setSize(s)
|
||||
}
|
||||
|
||||
function registerSubgraphNodeDef(
|
||||
nodeDefV1: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance
|
||||
) {
|
||||
const node = class ComfyNode extends SubgraphNode {
|
||||
const node = class ComfyNode
|
||||
extends SubgraphNode
|
||||
implements HasInitialMinSize
|
||||
{
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
_initialMinSize = { width: 1, height: 1 }
|
||||
|
||||
constructor() {
|
||||
super(app.graph, subgraph, instanceData)
|
||||
@@ -130,165 +290,14 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
})
|
||||
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
setupStrokeStyles(this)
|
||||
addInputs(this, ComfyNode.nodeData.inputs)
|
||||
addOutputs(this, ComfyNode.nodeData.outputs)
|
||||
setInitialSize(this)
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
@@ -369,29 +378,23 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
|
||||
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
||||
const node = class ComfyNode extends LGraphNode {
|
||||
const node = class ComfyNode
|
||||
extends LGraphNode
|
||||
implements HasInitialMinSize
|
||||
{
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
_initialMinSize = { width: 1, height: 1 }
|
||||
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
setupStrokeStyles(this)
|
||||
addInputs(this, ComfyNode.nodeData.inputs)
|
||||
addOutputs(this, ComfyNode.nodeData.outputs)
|
||||
setInitialSize(this)
|
||||
this.serialize_widgets = true
|
||||
|
||||
// Mark API Nodes yellow by default to distinguish with other nodes.
|
||||
@@ -403,168 +406,6 @@ export const useLitegraphService = () => {
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
// Check if this is an Asset Browser button widget
|
||||
const isAssetBrowserButton =
|
||||
widget.type === 'button' && widget.value === 'Select model'
|
||||
|
||||
if (isAssetBrowserButton) {
|
||||
// Preserve Asset Browser button label (don't translate)
|
||||
widget.label = String(widget.value)
|
||||
} else {
|
||||
// Apply normal translation for other widgets
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
}
|
||||
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
// Use input_order if available to ensure consistent widget ordering
|
||||
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
|
||||
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
|
||||
|
||||
// Create sockets and widgets in the determined order
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of orderedInputSpecs)
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
@@ -836,11 +677,12 @@ export const useLitegraphService = () => {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName') + ':',
|
||||
defaultValue: overWidget.label ?? overWidget.name
|
||||
defaultValue: overWidget.label,
|
||||
placeholder: overWidget.name
|
||||
})
|
||||
if (!newLabel) return
|
||||
overWidget.label = newLabel
|
||||
input.label = newLabel
|
||||
if (newLabel === null) return
|
||||
overWidget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
})
|
||||
@@ -1049,6 +891,7 @@ export const useLitegraphService = () => {
|
||||
registerNodeDef,
|
||||
registerSubgraphNodeDef,
|
||||
addNodeOnGraph,
|
||||
addNodeInput,
|
||||
getCanvasCenter,
|
||||
goToNode,
|
||||
resetView,
|
||||
|
||||
@@ -114,7 +114,10 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.graph) return []
|
||||
const allNodes = collectAllNodes(app.graph)
|
||||
if (!allNodes.length) return []
|
||||
if (!allNodes.length) {
|
||||
workflowPacks.value = []
|
||||
return []
|
||||
}
|
||||
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
}
|
||||
|
||||
@@ -1763,7 +1763,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('GeminiImageNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03 per 1K tokens')
|
||||
expect(price).toBe('~$0.039/Image (1K)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -277,6 +277,32 @@ describe('useMissingNodes', () => {
|
||||
// Should update missing packs (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clears missing nodes when switching to empty workflow', async () => {
|
||||
const workflowPacksRef = ref(mockWorkflowPacks)
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Should have missing nodes initially (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
|
||||
// Switch to empty workflow (simulates creating a new empty workflow)
|
||||
workflowPacksRef.value = []
|
||||
await nextTick()
|
||||
|
||||
// Should clear missing nodes
|
||||
expect(missingNodePacks.value).toHaveLength(0)
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
|
||||
90
tests-ui/tests/widgets/dynamicCombo.test.ts
Normal file
90
tests-ui/tests/widgets/dynamicCombo.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
const namePrefix = `${node.widgets?.length ?? 0}`
|
||||
function getSpec(
|
||||
inputs: DynamicInputs,
|
||||
depth: number = 0
|
||||
): { key: string; inputs: object }[] {
|
||||
return inputs.map((group, groupIndex) => {
|
||||
const inputs = group.map((input, inputIndex) => [
|
||||
`${namePrefix}.${depth}.${inputIndex}`,
|
||||
Array.isArray(input)
|
||||
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
|
||||
: [input, {}]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
inputs: { required: Object.fromEntries(inputs) }
|
||||
}
|
||||
})
|
||||
}
|
||||
const inputSpec: Required<InputSpec> = [
|
||||
'COMFY_DYNAMICCOMBO_V3',
|
||||
{ options: getSpec(inputs) }
|
||||
]
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function testNode() {
|
||||
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
|
||||
node.widgets = []
|
||||
node._initialMinSize = { width: 1, height: 1 }
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode'
|
||||
} as typeof node.constructor.nodeData
|
||||
return node as LGraphNode & Required<Pick<LGraphNode, 'widgets'>>
|
||||
}
|
||||
|
||||
describe('Dynamic Combos', () => {
|
||||
test('Can add widget on selection', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(3)
|
||||
})
|
||||
test('Can add nested widgets', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], [[[], ['STRING']]]])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[1].value = '1'
|
||||
expect(node.widgets.length).toBe(3)
|
||||
})
|
||||
test('Can add input', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
expect(node.widgets.length).toBe(2)
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(1)
|
||||
expect(node.inputs.length).toBe(2)
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
test('Dynamically added inputs are well ordered', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
node.widgets[2].value = '1'
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.widgets.length).toBe(2)
|
||||
expect(node.inputs.length).toBe(4)
|
||||
expect(node.inputs[1].name).toBe('0.0.0')
|
||||
expect(node.inputs[3].name).toBe('2.0.0')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ES2023.Array",
|
||||
"ESNext.Iterator",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user