Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
chenpangpang
ComfyUI
Commits
c92f3dca
Unverified
Commit
c92f3dca
authored
Dec 02, 2023
by
Jairo Correa
Committed by
GitHub
Dec 02, 2023
Browse files
Merge branch 'master' into image-cache
parents
006b24cc
2995a247
Changes
57
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
2516 additions
and
556 deletions
+2516
-556
tests-ui/utils/ezgraph.js
tests-ui/utils/ezgraph.js
+34
-12
tests-ui/utils/index.js
tests-ui/utils/index.js
+49
-14
tests-ui/utils/setup.js
tests-ui/utils/setup.js
+12
-8
web/extensions/core/colorPalette.js
web/extensions/core/colorPalette.js
+207
-0
web/extensions/core/groupNode.js
web/extensions/core/groupNode.js
+1053
-0
web/extensions/core/nodeTemplates.js
web/extensions/core/nodeTemplates.js
+40
-17
web/extensions/core/undoRedo.js
web/extensions/core/undoRedo.js
+150
-0
web/extensions/core/widgetInputs.js
web/extensions/core/widgetInputs.js
+122
-109
web/lib/litegraph.core.js
web/lib/litegraph.core.js
+9
-8
web/scripts/api.js
web/scripts/api.js
+2
-2
web/scripts/app.js
web/scripts/app.js
+247
-186
web/scripts/domWidget.js
web/scripts/domWidget.js
+322
-0
web/scripts/pnginfo.js
web/scripts/pnginfo.js
+4
-2
web/scripts/ui.js
web/scripts/ui.js
+5
-5
web/scripts/ui/imagePreview.js
web/scripts/ui/imagePreview.js
+97
-0
web/scripts/widgets.js
web/scripts/widgets.js
+148
-193
web/style.css
web/style.css
+15
-0
No files found.
tests-ui/utils/ezgraph.js
View file @
c92f3dca
...
...
@@ -150,7 +150,7 @@ export class EzNodeMenuItem {
if (selectNode) {
this.node.select();
}
this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node);
return
this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node);
}
}
...
...
@@ -240,8 +240,12 @@ export class EzNode {
return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem);
}
select() {
this.app.canvas.selectNode(this.node);
get isRemoved() {
return !this.app.graph.getNodeById(this.id);
}
select(addToSelection = false) {
this.app.canvas.selectNode(this.node, addToSelection);
}
// /**
...
...
@@ -275,12 +279,17 @@ export class EzNode {
if (!s) return p;
const name = s[nameProperty];
const item = new ctor(this, i, s);
// @ts-ignore
if (!name || name in p) {
throw new Error(`
Unable
to
store
$
{
nodeProperty
}
$
{
name
}
on
array
as
name
conflicts
.
`);
p.push(item);
if (name) {
// @ts-ignore
if (name in p) {
throw new Error(`
Unable
to
store
$
{
nodeProperty
}
$
{
name
}
on
array
as
name
conflicts
.
`);
}
}
// @ts-ignore
p.push((
p[name] =
new ctor(this, i, s)))
;
p[name] =
item
;
return p;
}, Object.assign([], { $: this }));
}
...
...
@@ -348,6 +357,19 @@ export class EzGraph {
}, 10);
});
}
/**
* @returns { Promise<{
* workflow: {},
* output: Record<string, {
* class_name: string,
* inputs: Record<string, [string, number] | unknown>
* }>}> }
*/
toPrompt() {
// @ts-ignore
return this.app.graphToPrompt();
}
}
export const Ez = {
...
...
@@ -356,12 +378,12 @@ export const Ez = {
* @example
* const { ez, graph } = Ez.graph(app);
* graph.clear();
* const [model, clip, vae] = ez.CheckpointLoaderSimple();
* const [pos] = ez.CLIPTextEncode(clip, { text: "positive" });
* const [neg] = ez.CLIPTextEncode(clip, { text: "negative" });
* const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage()
)
;
* const [image] = ez.VAEDecode(latent, vae);
* const saveNode = ez.SaveImage(image)
.node
;
* const [model, clip, vae] = ez.CheckpointLoaderSimple()
.outputs
;
* const [pos] = ez.CLIPTextEncode(clip, { text: "positive" })
.outputs
;
* const [neg] = ez.CLIPTextEncode(clip, { text: "negative" })
.outputs
;
* const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage()
.outputs).outputs
;
* const [image] = ez.VAEDecode(latent, vae)
.outputs
;
* const saveNode = ez.SaveImage(image);
* console.log(saveNode);
* graph.arrange();
* @param { app } app
...
...
tests-ui/utils/index.js
View file @
c92f3dca
const
{
mockApi
}
=
require
(
"
./setup
"
);
const
{
Ez
}
=
require
(
"
./ezgraph
"
);
const
lg
=
require
(
"
./litegraph
"
);
/**
*
* @param { Parameters<mockApi>[0] } config
* @param { Parameters<mockApi>[0]
& { resetEnv?: boolean, preSetup?(app): Promise<void> }
} config
* @returns
*/
export
async
function
start
(
config
=
undefined
)
{
export
async
function
start
(
config
=
{})
{
if
(
config
.
resetEnv
)
{
jest
.
resetModules
();
jest
.
resetAllMocks
();
lg
.
setup
(
global
);
}
mockApi
(
config
);
const
{
app
}
=
require
(
"
../../web/scripts/app
"
);
config
.
preSetup
?.(
app
);
await
app
.
setup
();
return
Ez
.
graph
(
app
,
global
[
"
LiteGraph
"
],
global
[
"
LGraphCanvas
"
]);
return
{
...
Ez
.
graph
(
app
,
global
[
"
LiteGraph
"
],
global
[
"
LGraphCanvas
"
])
,
app
}
;
}
/**
* @param { ReturnType<Ez["graph"]>["graph"] } graph
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
* @param { ReturnType<Ez["graph"]>["graph"] } graph
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
*/
export
async
function
checkBeforeAndAfterReload
(
graph
,
cb
)
{
await
cb
(
false
);
...
...
@@ -24,10 +32,10 @@ export async function checkBeforeAndAfterReload(graph, cb) {
}
/**
* @param { string } name
* @param { Record<string, string | [string | string[], any]> } input
* @param { string } name
* @param { Record<string, string | [string | string[], any]> } input
* @param { (string | string[])[] | Record<string, string | string[]> } output
* @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
* @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
*/
export
function
makeNodeDef
(
name
,
input
,
output
=
{})
{
const
nodeDef
=
{
...
...
@@ -37,19 +45,19 @@ export function makeNodeDef(name, input, output = {}) {
output_name
:
[],
output_is_list
:
[],
input
:
{
required
:
{}
required
:
{}
,
},
};
for
(
const
k
in
input
)
{
for
(
const
k
in
input
)
{
nodeDef
.
input
.
required
[
k
]
=
typeof
input
[
k
]
===
"
string
"
?
[
input
[
k
],
{}]
:
[...
input
[
k
]];
}
if
(
output
instanceof
Array
)
{
if
(
output
instanceof
Array
)
{
output
=
output
.
reduce
((
p
,
c
)
=>
{
p
[
c
]
=
c
;
return
p
;
},
{})
},
{})
;
}
for
(
const
k
in
output
)
{
for
(
const
k
in
output
)
{
nodeDef
.
output
.
push
(
output
[
k
]);
nodeDef
.
output_name
.
push
(
k
);
nodeDef
.
output_is_list
.
push
(
false
);
...
...
@@ -68,4 +76,31 @@ export function assertNotNullOrUndefined(x) {
expect
(
x
).
not
.
toEqual
(
null
);
expect
(
x
).
not
.
toEqual
(
undefined
);
return
true
;
}
\ No newline at end of file
}
/**
*
* @param { ReturnType<Ez["graph"]>["ez"] } ez
* @param { ReturnType<Ez["graph"]>["graph"] } graph
*/
export
function
createDefaultWorkflow
(
ez
,
graph
)
{
graph
.
clear
();
const
ckpt
=
ez
.
CheckpointLoaderSimple
();
const
pos
=
ez
.
CLIPTextEncode
(
ckpt
.
outputs
.
CLIP
,
{
text
:
"
positive
"
});
const
neg
=
ez
.
CLIPTextEncode
(
ckpt
.
outputs
.
CLIP
,
{
text
:
"
negative
"
});
const
empty
=
ez
.
EmptyLatentImage
();
const
sampler
=
ez
.
KSampler
(
ckpt
.
outputs
.
MODEL
,
pos
.
outputs
.
CONDITIONING
,
neg
.
outputs
.
CONDITIONING
,
empty
.
outputs
.
LATENT
);
const
decode
=
ez
.
VAEDecode
(
sampler
.
outputs
.
LATENT
,
ckpt
.
outputs
.
VAE
);
const
save
=
ez
.
SaveImage
(
decode
.
outputs
.
IMAGE
);
graph
.
arrange
();
return
{
ckpt
,
pos
,
neg
,
empty
,
sampler
,
decode
,
save
};
}
tests-ui/utils/setup.js
View file @
c92f3dca
...
...
@@ -30,16 +30,20 @@ export function mockApi({ mockExtensions, mockNodeDefs } = {}) {
mockNodeDefs
=
JSON
.
parse
(
fs
.
readFileSync
(
path
.
resolve
(
"
./data/object_info.json
"
)));
}
const
events
=
new
EventTarget
();
const
mockApi
=
{
addEventListener
:
events
.
addEventListener
.
bind
(
events
),
removeEventListener
:
events
.
removeEventListener
.
bind
(
events
),
dispatchEvent
:
events
.
dispatchEvent
.
bind
(
events
),
getSystemStats
:
jest
.
fn
(),
getExtensions
:
jest
.
fn
(()
=>
mockExtensions
),
getNodeDefs
:
jest
.
fn
(()
=>
mockNodeDefs
),
init
:
jest
.
fn
(),
apiURL
:
jest
.
fn
((
x
)
=>
"
../../web/
"
+
x
),
};
jest
.
mock
(
"
../../web/scripts/api
"
,
()
=>
({
get
api
()
{
return
{
addEventListener
:
jest
.
fn
(),
getSystemStats
:
jest
.
fn
(),
getExtensions
:
jest
.
fn
(()
=>
mockExtensions
),
getNodeDefs
:
jest
.
fn
(()
=>
mockNodeDefs
),
init
:
jest
.
fn
(),
apiURL
:
jest
.
fn
((
x
)
=>
"
../../web/
"
+
x
),
};
return
mockApi
;
},
}));
}
web/extensions/core/colorPalette.js
View file @
c92f3dca
...
...
@@ -174,6 +174,213 @@ const colorPalettes = {
"
tr-odd-bg-color
"
:
"
#073642
"
,
}
},
},
"
arc
"
:
{
"
id
"
:
"
arc
"
,
"
name
"
:
"
Arc
"
,
"
colors
"
:
{
"
node_slot
"
:
{
"
BOOLEAN
"
:
""
,
"
CLIP
"
:
"
#eacb8b
"
,
"
CLIP_VISION
"
:
"
#A8DADC
"
,
"
CLIP_VISION_OUTPUT
"
:
"
#ad7452
"
,
"
CONDITIONING
"
:
"
#cf876f
"
,
"
CONTROL_NET
"
:
"
#00d78d
"
,
"
CONTROL_NET_WEIGHTS
"
:
""
,
"
FLOAT
"
:
""
,
"
GLIGEN
"
:
""
,
"
IMAGE
"
:
"
#80a1c0
"
,
"
IMAGEUPLOAD
"
:
""
,
"
INT
"
:
""
,
"
LATENT
"
:
"
#b38ead
"
,
"
LATENT_KEYFRAME
"
:
""
,
"
MASK
"
:
"
#a3bd8d
"
,
"
MODEL
"
:
"
#8978a7
"
,
"
SAMPLER
"
:
""
,
"
SIGMAS
"
:
""
,
"
STRING
"
:
""
,
"
STYLE_MODEL
"
:
"
#C2FFAE
"
,
"
T2I_ADAPTER_WEIGHTS
"
:
""
,
"
TAESD
"
:
"
#DCC274
"
,
"
TIMESTEP_KEYFRAME
"
:
""
,
"
UPSCALE_MODEL
"
:
""
,
"
VAE
"
:
"
#be616b
"
},
"
litegraph_base
"
:
{
"
BACKGROUND_IMAGE
"
:
"
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABcklEQVR4nO3YMUoDARgF4RfxBqZI6/0vZqFn0MYtrLIQMFN8U6V4LAtD+Jm9XG/v30OGl2e/AP7yevz4+vx45nvgF/+QGITEICQGITEIiUFIjNNC3q43u3/YnRJyPOzeQ+0e220nhRzReC8e7R7bbdvl+Jal1Bs46jEIiUFIDEJiEBKDkBhKPbZT6qHdptRTu02p53DUYxASg5AYhMQgJAYhMZR6bKfUQ7tNqad2m1LP4ajHICQGITEIiUFIDEJiKPXYTqmHdptST+02pZ7DUY9BSAxCYhASg5AYhMRQ6rGdUg/tNqWe2m1KPYejHoOQGITEICQGITEIiaHUYzulHtptSj2125R6Dkc9BiExCIlBSAxCYhASQ6nHdko9tNuUemq3KfUcjnoMQmIQEoOQGITEICSGUo/tlHpotyn11G5T6jkc9RiExCAkBiExCIlBSAylHtsp9dBuU+qp3abUczjqMQiJQUgMQmIQEoOQGITE+AHFISNQrFTGuwAAAABJRU5ErkJggg==
"
,
"
CLEAR_BACKGROUND_COLOR
"
:
"
#2b2f38
"
,
"
NODE_TITLE_COLOR
"
:
"
#b2b7bd
"
,
"
NODE_SELECTED_TITLE_COLOR
"
:
"
#FFF
"
,
"
NODE_TEXT_SIZE
"
:
14
,
"
NODE_TEXT_COLOR
"
:
"
#AAA
"
,
"
NODE_SUBTEXT_SIZE
"
:
12
,
"
NODE_DEFAULT_COLOR
"
:
"
#2b2f38
"
,
"
NODE_DEFAULT_BGCOLOR
"
:
"
#242730
"
,
"
NODE_DEFAULT_BOXCOLOR
"
:
"
#6e7581
"
,
"
NODE_DEFAULT_SHAPE
"
:
"
box
"
,
"
NODE_BOX_OUTLINE_COLOR
"
:
"
#FFF
"
,
"
DEFAULT_SHADOW_COLOR
"
:
"
rgba(0,0,0,0.5)
"
,
"
DEFAULT_GROUP_FONT
"
:
22
,
"
WIDGET_BGCOLOR
"
:
"
#2b2f38
"
,
"
WIDGET_OUTLINE_COLOR
"
:
"
#6e7581
"
,
"
WIDGET_TEXT_COLOR
"
:
"
#DDD
"
,
"
WIDGET_SECONDARY_TEXT_COLOR
"
:
"
#b2b7bd
"
,
"
LINK_COLOR
"
:
"
#9A9
"
,
"
EVENT_LINK_COLOR
"
:
"
#A86
"
,
"
CONNECTING_LINK_COLOR
"
:
"
#AFA
"
},
"
comfy_base
"
:
{
"
fg-color
"
:
"
#fff
"
,
"
bg-color
"
:
"
#2b2f38
"
,
"
comfy-menu-bg
"
:
"
#242730
"
,
"
comfy-input-bg
"
:
"
#2b2f38
"
,
"
input-text
"
:
"
#ddd
"
,
"
descrip-text
"
:
"
#b2b7bd
"
,
"
drag-text
"
:
"
#ccc
"
,
"
error-text
"
:
"
#ff4444
"
,
"
border-color
"
:
"
#6e7581
"
,
"
tr-even-bg-color
"
:
"
#2b2f38
"
,
"
tr-odd-bg-color
"
:
"
#242730
"
}
},
},
"
nord
"
:
{
"
id
"
:
"
nord
"
,
"
name
"
:
"
Nord
"
,
"
colors
"
:
{
"
node_slot
"
:
{
"
BOOLEAN
"
:
""
,
"
CLIP
"
:
"
#eacb8b
"
,
"
CLIP_VISION
"
:
"
#A8DADC
"
,
"
CLIP_VISION_OUTPUT
"
:
"
#ad7452
"
,
"
CONDITIONING
"
:
"
#cf876f
"
,
"
CONTROL_NET
"
:
"
#00d78d
"
,
"
CONTROL_NET_WEIGHTS
"
:
""
,
"
FLOAT
"
:
""
,
"
GLIGEN
"
:
""
,
"
IMAGE
"
:
"
#80a1c0
"
,
"
IMAGEUPLOAD
"
:
""
,
"
INT
"
:
""
,
"
LATENT
"
:
"
#b38ead
"
,
"
LATENT_KEYFRAME
"
:
""
,
"
MASK
"
:
"
#a3bd8d
"
,
"
MODEL
"
:
"
#8978a7
"
,
"
SAMPLER
"
:
""
,
"
SIGMAS
"
:
""
,
"
STRING
"
:
""
,
"
STYLE_MODEL
"
:
"
#C2FFAE
"
,
"
T2I_ADAPTER_WEIGHTS
"
:
""
,
"
TAESD
"
:
"
#DCC274
"
,
"
TIMESTEP_KEYFRAME
"
:
""
,
"
UPSCALE_MODEL
"
:
""
,
"
VAE
"
:
"
#be616b
"
},
"
litegraph_base
"
:
{
"
BACKGROUND_IMAGE
"
:
"
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFu2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUwNDFhMmZjLTEzNzQtMTk0ZC1hZWY4LTYxMzM1MTVmNjUwMCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoyMzFiMTBiMC1iNGZiLTAyNGUtYjEyZS0zMDUzMDNjZDA3YzgiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1MDQxYTJmYy0xMzc0LTE5NGQtYWVmOC02MTMzNTE1ZjY1MDAiIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDE6MjA6NDUrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz73jWg/AAAAyUlEQVR42u3WKwoAIBRFQRdiMb1idv9Lsxn9gEFw4Dbb8JCTojbbXEJwjJVL2HKwYMGCBQuWLbDmjr+9zrBGjHl1WVcvy2DBggULFizTWQpewSt4HzwsgwULFiwFr7MUvMtS8D54WLBgGSxYCl7BK3iXZbBgwYIFC5bpLAWv4BW8Dx6WwYIFC5aC11kK3mUpeB88LFiwDBYsBa/gFbzLMliwYMGCBct0loJX8AreBw/LYMGCBUvB6ywF77IUvA8eFixYBgsWrNfWAZPltufdad+1AAAAAElFTkSuQmCC
"
,
"
CLEAR_BACKGROUND_COLOR
"
:
"
#212732
"
,
"
NODE_TITLE_COLOR
"
:
"
#999
"
,
"
NODE_SELECTED_TITLE_COLOR
"
:
"
#e5eaf0
"
,
"
NODE_TEXT_SIZE
"
:
14
,
"
NODE_TEXT_COLOR
"
:
"
#bcc2c8
"
,
"
NODE_SUBTEXT_SIZE
"
:
12
,
"
NODE_DEFAULT_COLOR
"
:
"
#2e3440
"
,
"
NODE_DEFAULT_BGCOLOR
"
:
"
#161b22
"
,
"
NODE_DEFAULT_BOXCOLOR
"
:
"
#545d70
"
,
"
NODE_DEFAULT_SHAPE
"
:
"
box
"
,
"
NODE_BOX_OUTLINE_COLOR
"
:
"
#e5eaf0
"
,
"
DEFAULT_SHADOW_COLOR
"
:
"
rgba(0,0,0,0.5)
"
,
"
DEFAULT_GROUP_FONT
"
:
24
,
"
WIDGET_BGCOLOR
"
:
"
#2e3440
"
,
"
WIDGET_OUTLINE_COLOR
"
:
"
#545d70
"
,
"
WIDGET_TEXT_COLOR
"
:
"
#bcc2c8
"
,
"
WIDGET_SECONDARY_TEXT_COLOR
"
:
"
#999
"
,
"
LINK_COLOR
"
:
"
#9A9
"
,
"
EVENT_LINK_COLOR
"
:
"
#A86
"
,
"
CONNECTING_LINK_COLOR
"
:
"
#AFA
"
},
"
comfy_base
"
:
{
"
fg-color
"
:
"
#e5eaf0
"
,
"
bg-color
"
:
"
#2e3440
"
,
"
comfy-menu-bg
"
:
"
#161b22
"
,
"
comfy-input-bg
"
:
"
#2e3440
"
,
"
input-text
"
:
"
#bcc2c8
"
,
"
descrip-text
"
:
"
#999
"
,
"
drag-text
"
:
"
#ccc
"
,
"
error-text
"
:
"
#ff4444
"
,
"
border-color
"
:
"
#545d70
"
,
"
tr-even-bg-color
"
:
"
#2e3440
"
,
"
tr-odd-bg-color
"
:
"
#161b22
"
}
},
},
"
github
"
:
{
"
id
"
:
"
github
"
,
"
name
"
:
"
Github
"
,
"
colors
"
:
{
"
node_slot
"
:
{
"
BOOLEAN
"
:
""
,
"
CLIP
"
:
"
#eacb8b
"
,
"
CLIP_VISION
"
:
"
#A8DADC
"
,
"
CLIP_VISION_OUTPUT
"
:
"
#ad7452
"
,
"
CONDITIONING
"
:
"
#cf876f
"
,
"
CONTROL_NET
"
:
"
#00d78d
"
,
"
CONTROL_NET_WEIGHTS
"
:
""
,
"
FLOAT
"
:
""
,
"
GLIGEN
"
:
""
,
"
IMAGE
"
:
"
#80a1c0
"
,
"
IMAGEUPLOAD
"
:
""
,
"
INT
"
:
""
,
"
LATENT
"
:
"
#b38ead
"
,
"
LATENT_KEYFRAME
"
:
""
,
"
MASK
"
:
"
#a3bd8d
"
,
"
MODEL
"
:
"
#8978a7
"
,
"
SAMPLER
"
:
""
,
"
SIGMAS
"
:
""
,
"
STRING
"
:
""
,
"
STYLE_MODEL
"
:
"
#C2FFAE
"
,
"
T2I_ADAPTER_WEIGHTS
"
:
""
,
"
TAESD
"
:
"
#DCC274
"
,
"
TIMESTEP_KEYFRAME
"
:
""
,
"
UPSCALE_MODEL
"
:
""
,
"
VAE
"
:
"
#be616b
"
},
"
litegraph_base
"
:
{
"
BACKGROUND_IMAGE
"
:
"
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGlmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDEgNzkuMTQ2Mjg5OSwgMjAyMy8wNi8yNS0yMDowMTo1NSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0xMS0xM1QwMDoxODowMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmIyYzRhNjA5LWJmYTctYTg0MC1iOGFlLTk3MzE2ZjM1ZGIyNyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjk0ZmNlZGU4LTE1MTctZmQ0MC04ZGU3LWYzOTgxM2E3ODk5ZiIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjIzMWIxMGIwLWI0ZmItMDI0ZS1iMTJlLTMwNTMwM2NkMDdjOCI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MjMxYjEwYjAtYjRmYi0wMjRlLWIxMmUtMzA1MzAzY2QwN2M4IiBzdEV2dDp3aGVuPSIyMDIzLTExLTEzVDAwOjE4OjAyKzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjUuMSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjQ4OWY1NzlmLTJkNjUtZWQ0Zi04OTg0LTA4NGE2MGE1ZTMzNSIgc3RFdnQ6d2hlbj0iMjAyMy0xMS0xNVQwMjowNDo1OSswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjEgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMmM0YTYwOS1iZmE3LWE4NDAtYjhhZS05NzMxNmYzNWRiMjciIHN0RXZ0OndoZW49IjIwMjMtMTEtMTVUMDI6MDQ6NTkrMDE6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4OTe6GAAAAx0lEQVR42u3WMQoAIQxFwRzJys77X8vSLiRgITif7bYbgrwYc/mKXyBoY4VVBgsWLFiwYFmOlTv+9jfDOjHmr8u6eVkGCxYsWLBgmc5S8ApewXvgYRksWLBgKXidpeBdloL3wMOCBctgwVLwCl7BuyyDBQsWLFiwTGcpeAWv4D3wsAwWLFiwFLzOUvAuS8F74GHBgmWwYCl4Ba/gXZbBggULFixYprMUvIJX8B54WAYLFixYCl5nKXiXpeA98LBgwTJYsGC9tg1o8f4TTtqzNQAAAABJRU5ErkJggg==
"
,
"
CLEAR_BACKGROUND_COLOR
"
:
"
#040506
"
,
"
NODE_TITLE_COLOR
"
:
"
#999
"
,
"
NODE_SELECTED_TITLE_COLOR
"
:
"
#e5eaf0
"
,
"
NODE_TEXT_SIZE
"
:
14
,
"
NODE_TEXT_COLOR
"
:
"
#bcc2c8
"
,
"
NODE_SUBTEXT_SIZE
"
:
12
,
"
NODE_DEFAULT_COLOR
"
:
"
#161b22
"
,
"
NODE_DEFAULT_BGCOLOR
"
:
"
#13171d
"
,
"
NODE_DEFAULT_BOXCOLOR
"
:
"
#30363d
"
,
"
NODE_DEFAULT_SHAPE
"
:
"
box
"
,
"
NODE_BOX_OUTLINE_COLOR
"
:
"
#e5eaf0
"
,
"
DEFAULT_SHADOW_COLOR
"
:
"
rgba(0,0,0,0.5)
"
,
"
DEFAULT_GROUP_FONT
"
:
24
,
"
WIDGET_BGCOLOR
"
:
"
#161b22
"
,
"
WIDGET_OUTLINE_COLOR
"
:
"
#30363d
"
,
"
WIDGET_TEXT_COLOR
"
:
"
#bcc2c8
"
,
"
WIDGET_SECONDARY_TEXT_COLOR
"
:
"
#999
"
,
"
LINK_COLOR
"
:
"
#9A9
"
,
"
EVENT_LINK_COLOR
"
:
"
#A86
"
,
"
CONNECTING_LINK_COLOR
"
:
"
#AFA
"
},
"
comfy_base
"
:
{
"
fg-color
"
:
"
#e5eaf0
"
,
"
bg-color
"
:
"
#161b22
"
,
"
comfy-menu-bg
"
:
"
#13171d
"
,
"
comfy-input-bg
"
:
"
#161b22
"
,
"
input-text
"
:
"
#bcc2c8
"
,
"
descrip-text
"
:
"
#999
"
,
"
drag-text
"
:
"
#ccc
"
,
"
error-text
"
:
"
#ff4444
"
,
"
border-color
"
:
"
#30363d
"
,
"
tr-even-bg-color
"
:
"
#161b22
"
,
"
tr-odd-bg-color
"
:
"
#13171d
"
}
},
}
};
...
...
web/extensions/core/groupNode.js
0 → 100644
View file @
c92f3dca
import
{
app
}
from
"
../../scripts/app.js
"
;
import
{
api
}
from
"
../../scripts/api.js
"
;
import
{
mergeIfValid
}
from
"
./widgetInputs.js
"
;
const
GROUP
=
Symbol
();
const
Workflow
=
{
InUse
:
{
Free
:
0
,
Registered
:
1
,
InWorkflow
:
2
,
},
isInUseGroupNode
(
name
)
{
const
id
=
`workflow/
${
name
}
`
;
// Check if lready registered/in use in this workflow
if
(
app
.
graph
.
extra
?.
groupNodes
?.[
name
])
{
if
(
app
.
graph
.
_nodes
.
find
((
n
)
=>
n
.
type
===
id
))
{
return
Workflow
.
InUse
.
InWorkflow
;
}
else
{
return
Workflow
.
InUse
.
Registered
;
}
}
return
Workflow
.
InUse
.
Free
;
},
storeGroupNode
(
name
,
data
)
{
let
extra
=
app
.
graph
.
extra
;
if
(
!
extra
)
app
.
graph
.
extra
=
extra
=
{};
let
groupNodes
=
extra
.
groupNodes
;
if
(
!
groupNodes
)
extra
.
groupNodes
=
groupNodes
=
{};
groupNodes
[
name
]
=
data
;
},
};
class
GroupNodeBuilder
{
constructor
(
nodes
)
{
this
.
nodes
=
nodes
;
}
build
()
{
const
name
=
this
.
getName
();
if
(
!
name
)
return
;
// Sort the nodes so they are in execution order
// this allows for widgets to be in the correct order when reconstructing
this
.
sortNodes
();
this
.
nodeData
=
this
.
getNodeData
();
Workflow
.
storeGroupNode
(
name
,
this
.
nodeData
);
return
{
name
,
nodeData
:
this
.
nodeData
};
}
getName
()
{
const
name
=
prompt
(
"
Enter group name
"
);
if
(
!
name
)
return
;
const
used
=
Workflow
.
isInUseGroupNode
(
name
);
switch
(
used
)
{
case
Workflow
.
InUse
.
InWorkflow
:
alert
(
"
An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name.
"
);
return
;
case
Workflow
.
InUse
.
Registered
:
if
(
!
confirm
(
"
An group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?
"
)
)
{
return
;
}
break
;
}
return
name
;
}
sortNodes
()
{
// Gets the builders nodes in graph execution order
const
nodesInOrder
=
app
.
graph
.
computeExecutionOrder
(
false
);
this
.
nodes
=
this
.
nodes
.
map
((
node
)
=>
({
index
:
nodesInOrder
.
indexOf
(
node
),
node
}))
.
sort
((
a
,
b
)
=>
a
.
index
-
b
.
index
||
a
.
node
.
id
-
b
.
node
.
id
)
.
map
(({
node
})
=>
node
);
}
getNodeData
()
{
const
storeLinkTypes
=
(
config
)
=>
{
// Store link types for dynamically typed nodes e.g. reroutes
for
(
const
link
of
config
.
links
)
{
const
origin
=
app
.
graph
.
getNodeById
(
link
[
4
]);
const
type
=
origin
.
outputs
[
link
[
1
]].
type
;
link
.
push
(
type
);
}
};
const
storeExternalLinks
=
(
config
)
=>
{
// Store any external links to the group in the config so when rebuilding we add extra slots
config
.
external
=
[];
for
(
let
i
=
0
;
i
<
this
.
nodes
.
length
;
i
++
)
{
const
node
=
this
.
nodes
[
i
];
if
(
!
node
.
outputs
?.
length
)
continue
;
for
(
let
slot
=
0
;
slot
<
node
.
outputs
.
length
;
slot
++
)
{
let
hasExternal
=
false
;
const
output
=
node
.
outputs
[
slot
];
let
type
=
output
.
type
;
if
(
!
output
.
links
?.
length
)
continue
;
for
(
const
l
of
output
.
links
)
{
const
link
=
app
.
graph
.
links
[
l
];
if
(
!
link
)
continue
;
if
(
type
===
"
*
"
)
type
=
link
.
type
;
if
(
!
app
.
canvas
.
selected_nodes
[
link
.
target_id
])
{
hasExternal
=
true
;
break
;
}
}
if
(
hasExternal
)
{
config
.
external
.
push
([
i
,
slot
,
type
]);
}
}
}
};
// Use the built in copyToClipboard function to generate the node data we need
const
backup
=
localStorage
.
getItem
(
"
litegrapheditor_clipboard
"
);
try
{
app
.
canvas
.
copyToClipboard
(
this
.
nodes
);
const
config
=
JSON
.
parse
(
localStorage
.
getItem
(
"
litegrapheditor_clipboard
"
));
storeLinkTypes
(
config
);
storeExternalLinks
(
config
);
return
config
;
}
finally
{
localStorage
.
setItem
(
"
litegrapheditor_clipboard
"
,
backup
);
}
}
}
export
class
GroupNodeConfig
{
constructor
(
name
,
nodeData
)
{
this
.
name
=
name
;
this
.
nodeData
=
nodeData
;
this
.
getLinks
();
this
.
inputCount
=
0
;
this
.
oldToNewOutputMap
=
{};
this
.
newToOldOutputMap
=
{};
this
.
oldToNewInputMap
=
{};
this
.
oldToNewWidgetMap
=
{};
this
.
newToOldWidgetMap
=
{};
this
.
primitiveDefs
=
{};
this
.
widgetToPrimitive
=
{};
this
.
primitiveToWidget
=
{};
}
async
registerType
(
source
=
"
workflow
"
)
{
this
.
nodeDef
=
{
output
:
[],
output_name
:
[],
output_is_list
:
[],
name
:
source
+
"
/
"
+
this
.
name
,
display_name
:
this
.
name
,
category
:
"
group nodes
"
+
(
"
/
"
+
source
),
input
:
{
required
:
{}
},
[
GROUP
]:
this
,
};
this
.
inputs
=
[];
const
seenInputs
=
{};
const
seenOutputs
=
{};
for
(
let
i
=
0
;
i
<
this
.
nodeData
.
nodes
.
length
;
i
++
)
{
const
node
=
this
.
nodeData
.
nodes
[
i
];
node
.
index
=
i
;
this
.
processNode
(
node
,
seenInputs
,
seenOutputs
);
}
await
app
.
registerNodeDef
(
"
workflow/
"
+
this
.
name
,
this
.
nodeDef
);
}
getLinks
()
{
this
.
linksFrom
=
{};
this
.
linksTo
=
{};
this
.
externalFrom
=
{};
// Extract links for easy lookup
for
(
const
l
of
this
.
nodeData
.
links
)
{
const
[
sourceNodeId
,
sourceNodeSlot
,
targetNodeId
,
targetNodeSlot
]
=
l
;
// Skip links outside the copy config
if
(
sourceNodeId
==
null
)
continue
;
if
(
!
this
.
linksFrom
[
sourceNodeId
])
{
this
.
linksFrom
[
sourceNodeId
]
=
{};
}
this
.
linksFrom
[
sourceNodeId
][
sourceNodeSlot
]
=
l
;
if
(
!
this
.
linksTo
[
targetNodeId
])
{
this
.
linksTo
[
targetNodeId
]
=
{};
}
this
.
linksTo
[
targetNodeId
][
targetNodeSlot
]
=
l
;
}
if
(
this
.
nodeData
.
external
)
{
for
(
const
ext
of
this
.
nodeData
.
external
)
{
if
(
!
this
.
externalFrom
[
ext
[
0
]])
{
this
.
externalFrom
[
ext
[
0
]]
=
{
[
ext
[
1
]]:
ext
[
2
]
};
}
else
{
this
.
externalFrom
[
ext
[
0
]][
ext
[
1
]]
=
ext
[
2
];
}
}
}
}
processNode
(
node
,
seenInputs
,
seenOutputs
)
{
const
def
=
this
.
getNodeDef
(
node
);
if
(
!
def
)
return
;
const
inputs
=
{
...
def
.
input
?.
required
,
...
def
.
input
?.
optional
};
this
.
inputs
.
push
(
this
.
processNodeInputs
(
node
,
seenInputs
,
inputs
));
if
(
def
.
output
?.
length
)
this
.
processNodeOutputs
(
node
,
seenOutputs
,
def
);
}
getNodeDef
(
node
)
{
const
def
=
globalDefs
[
node
.
type
];
if
(
def
)
return
def
;
const
linksFrom
=
this
.
linksFrom
[
node
.
index
];
if
(
node
.
type
===
"
PrimitiveNode
"
)
{
// Skip as its not linked
if
(
!
linksFrom
)
return
;
let
type
=
linksFrom
[
"
0
"
][
5
];
if
(
type
===
"
COMBO
"
)
{
// Use the array items
const
source
=
node
.
outputs
[
0
].
widget
.
name
;
const
fromTypeName
=
this
.
nodeData
.
nodes
[
linksFrom
[
"
0
"
][
2
]].
type
;
const
fromType
=
globalDefs
[
fromTypeName
];
const
input
=
fromType
.
input
.
required
[
source
]
??
fromType
.
input
.
optional
[
source
];
type
=
input
[
0
];
}
const
def
=
(
this
.
primitiveDefs
[
node
.
index
]
=
{
input
:
{
required
:
{
value
:
[
type
,
{}],
},
},
output
:
[
type
],
output_name
:
[],
output_is_list
:
[],
});
return
def
;
}
else
if
(
node
.
type
===
"
Reroute
"
)
{
const
linksTo
=
this
.
linksTo
[
node
.
index
];
if
(
linksTo
&&
linksFrom
&&
!
this
.
externalFrom
[
node
.
index
]?.[
0
])
{
// Being used internally
return
null
;
}
let
rerouteType
=
"
*
"
;
if
(
linksFrom
)
{
const
[,
,
id
,
slot
]
=
linksFrom
[
"
0
"
];
rerouteType
=
this
.
nodeData
.
nodes
[
id
].
inputs
[
slot
].
type
;
}
else
if
(
linksTo
)
{
const
[
id
,
slot
]
=
linksTo
[
"
0
"
];
rerouteType
=
this
.
nodeData
.
nodes
[
id
].
outputs
[
slot
].
type
;
}
else
{
// Reroute used as a pipe
for
(
const
l
of
this
.
nodeData
.
links
)
{
if
(
l
[
2
]
===
node
.
index
)
{
rerouteType
=
l
[
5
];
break
;
}
}
if
(
rerouteType
===
"
*
"
)
{
// Check for an external link
const
t
=
this
.
externalFrom
[
node
.
index
]?.[
0
];
if
(
t
)
{
rerouteType
=
t
;
}
}
}
return
{
input
:
{
required
:
{
[
rerouteType
]:
[
rerouteType
,
{}],
},
},
output
:
[
rerouteType
],
output_name
:
[],
output_is_list
:
[],
};
}
console
.
warn
(
"
Skipping virtual node
"
+
node
.
type
+
"
when building group node
"
+
this
.
name
);
}
getInputConfig
(
node
,
inputName
,
seenInputs
,
config
,
extra
)
{
let
name
=
node
.
inputs
?.
find
((
inp
)
=>
inp
.
name
===
inputName
)?.
label
??
inputName
;
let
prefix
=
""
;
// Special handling for primitive to include the title if it is set rather than just "value"
if
((
node
.
type
===
"
PrimitiveNode
"
&&
node
.
title
)
||
name
in
seenInputs
)
{
prefix
=
`
${
node
.
title
??
node
.
type
}
`;
name = `
$
{
prefix
}
$
{
inputName
}
`;
if (name in seenInputs) {
name = `
$
{
prefix
}
$
{
seenInputs
[
name
]}
$
{
inputName
}
`;
}
}
seenInputs[name] = (seenInputs[name] ?? 1) + 1;
if (inputName === "seed" || inputName === "noise_seed") {
if (!extra) extra = {};
extra.control_after_generate = `
$
{
prefix
}
control_after_generate
`;
}
if (config[0] === "IMAGEUPLOAD") {
if (!extra) extra = {};
extra.widget = `
$
{
prefix
}
$
{
config
[
1
]?.
widget
??
"
image
"
}
`;
}
if (extra) {
config = [config[0], { ...config[1], ...extra }];
}
return { name, config };
}
processWidgetInputs(inputs, node, inputNames, seenInputs) {
const slots = [];
const converted = new Map();
const widgetMap = (this.oldToNewWidgetMap[node.index] = {});
for (const inputName of inputNames) {
let widgetType = app.getWidgetType(inputs[inputName], inputName);
if (widgetType) {
const convertedIndex = node.inputs?.findIndex(
(inp) => inp.name === inputName && inp.widget?.name === inputName
);
if (convertedIndex > -1) {
// This widget has been converted to a widget
// We need to store this in the correct position so link ids line up
converted.set(convertedIndex, inputName);
widgetMap[inputName] = null;
} else {
// Normal widget
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
this.nodeDef.input.required[name] = config;
widgetMap[inputName] = name;
this.newToOldWidgetMap[name] = { node, inputName };
}
} else {
// Normal input
slots.push(inputName);
}
}
return { converted, slots };
}
checkPrimitiveConnection(link, inputName, inputs) {
const sourceNode = this.nodeData.nodes[link[0]];
if (sourceNode.type === "PrimitiveNode") {
// Merge link configurations
const [sourceNodeId, _, targetNodeId, __] = link;
const primitiveDef = this.primitiveDefs[sourceNodeId];
const targetWidget = inputs[inputName];
const primitiveConfig = primitiveDef.input.required.value;
const output = { widget: primitiveConfig };
const config = mergeIfValid(output, targetWidget, false, null, primitiveConfig);
primitiveConfig[1] = config?.customConfig ?? inputs[inputName][1] ? { ...inputs[inputName][1] } : {};
let name = this.oldToNewWidgetMap[sourceNodeId]["value"];
name = name.substr(0, name.length - 6);
primitiveConfig[1].control_after_generate = true;
primitiveConfig[1].control_prefix = name;
let toPrimitive = this.widgetToPrimitive[targetNodeId];
if (!toPrimitive) {
toPrimitive = this.widgetToPrimitive[targetNodeId] = {};
}
if (toPrimitive[inputName]) {
toPrimitive[inputName].push(sourceNodeId);
}
toPrimitive[inputName] = sourceNodeId;
let toWidget = this.primitiveToWidget[sourceNodeId];
if (!toWidget) {
toWidget = this.primitiveToWidget[sourceNodeId] = [];
}
toWidget.push({ nodeId: targetNodeId, inputName });
}
}
processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
for (let i = 0; i < slots.length; i++) {
const inputName = slots[i];
if (linksTo[i]) {
this.checkPrimitiveConnection(linksTo[i], inputName, inputs);
// This input is linked so we can skip it
continue;
}
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
this.nodeDef.input.required[name] = config;
inputMap[i] = this.inputCount++;
}
}
processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs) {
// Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
const convertedSlots = [...converted.keys()].sort().map((k) => converted.get(k));
for (let i = 0; i < convertedSlots.length; i++) {
const inputName = convertedSlots[i];
if (linksTo[slots.length + i]) {
this.checkPrimitiveConnection(linksTo[slots.length + i], inputName, inputs);
// This input is linked so we can skip it
continue;
}
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
defaultInput: true,
});
this.nodeDef.input.required[name] = config;
inputMap[slots.length + i] = this.inputCount++;
}
}
processNodeInputs(node, seenInputs, inputs) {
const inputMapping = [];
const inputNames = Object.keys(inputs);
if (!inputNames.length) return;
const { converted, slots } = this.processWidgetInputs(inputs, node, inputNames, seenInputs);
const linksTo = this.linksTo[node.index] ?? {};
const inputMap = (this.oldToNewInputMap[node.index] = {});
this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs);
this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs);
return inputMapping;
}
processNodeOutputs(node, seenOutputs, def) {
const oldToNew = (this.oldToNewOutputMap[node.index] = {});
// Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) {
const linksFrom = this.linksFrom[node.index];
if (linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]) {
// This output is linked internally so we can skip it
continue;
}
oldToNew[outputId] = this.nodeDef.output.length;
this.newToOldOutputMap[this.nodeDef.output.length] = { node, slot: outputId };
this.nodeDef.output.push(def.output[outputId]);
this.nodeDef.output_is_list.push(def.output_is_list[outputId]);
let label = def.output_name?.[outputId] ?? def.output[outputId];
const output = node.outputs.find((o) => o.name === label);
if (output?.label) {
label = output.label;
}
let name = label;
if (name in seenOutputs) {
const prefix = `
$
{
node
.
title
??
node
.
type
}
`;
name = `
$
{
prefix
}
$
{
label
}
`;
if (name in seenOutputs) {
name = `
$
{
prefix
}
$
{
node
.
index
}
$
{
label
}
`;
}
}
seenOutputs[name] = 1;
this.nodeDef.output_name.push(name);
}
}
static async registerFromWorkflow(groupNodes, missingNodeTypes) {
for (const g in groupNodes) {
const groupData = groupNodes[g];
let hasMissing = false;
for (const n of groupData.nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
missingNodeTypes.push(n.type);
hasMissing = true;
}
}
if (hasMissing) continue;
const config = new GroupNodeConfig(g, groupData);
await config.registerType();
}
}
}
export class GroupNodeHandler {
node;
groupData;
constructor(node) {
this.node = node;
this.groupData = node.constructor?.nodeData?.[GROUP];
this.node.setInnerNodes = (innerNodes) => {
this.innerNodes = innerNodes;
for (let innerNodeIndex = 0; innerNodeIndex < this.innerNodes.length; innerNodeIndex++) {
const innerNode = this.innerNodes[innerNodeIndex];
for (const w of innerNode.widgets ?? []) {
if (w.type === "converted-widget") {
w.serializeValue = w.origSerializeValue;
}
}
innerNode.index = innerNodeIndex;
innerNode.getInputNode = (slot) => {
// Check if this input is internal or external
const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
if (externalSlot != null) {
return this.node.getInputNode(externalSlot);
}
// Internal link
const innerLink = this.groupData.linksTo[innerNode.index]?.[slot];
if (!innerLink) return null;
const inputNode = innerNodes[innerLink[0]];
// Primitives will already apply their values
if (inputNode.type === "PrimitiveNode") return null;
return inputNode;
};
innerNode.getInputLink = (slot) => {
const externalSlot = this.groupData.oldToNewInputMap[innerNode.index]?.[slot];
if (externalSlot != null) {
// The inner node is connected via the group node inputs
const linkId = this.node.inputs[externalSlot].link;
let link = app.graph.links[linkId];
// Use the outer link, but update the target to the inner node
link = {
...link,
target_id: innerNode.id,
target_slot: +slot,
};
return link;
}
let link = this.groupData.linksTo[innerNode.index]?.[slot];
if (!link) return null;
// Use the inner link, but update the origin node to be inner node id
link = {
origin_id: innerNodes[link[0]].id,
origin_slot: link[1],
target_id: innerNode.id,
target_slot: +slot,
};
return link;
};
}
};
this.node.updateLink = (link) => {
// Replace the group node reference with the internal node
link = { ...link };
const output = this.groupData.newToOldOutputMap[link.origin_slot];
let innerNode = this.innerNodes[output.node.index];
let l;
while (innerNode.type === "Reroute") {
l = innerNode.getInputLink(0);
innerNode = innerNode.getInputNode(0);
}
link.origin_id = innerNode.id;
link.origin_slot = l?.origin_slot ?? output.slot;
return link;
};
this.node.getInnerNodes = () => {
if (!this.innerNodes) {
this.node.setInnerNodes(
this.groupData.nodeData.nodes.map((n, i) => {
const innerNode = LiteGraph.createNode(n.type);
innerNode.configure(n);
innerNode.id = `
$
{
this
.
node
.
id
}:
$
{
i
}
`;
return innerNode;
})
);
}
this.updateInnerWidgets();
return this.innerNodes;
};
this.node.convertToNodes = () => {
const addInnerNodes = () => {
const backup = localStorage.getItem("litegrapheditor_clipboard");
// Clone the node data so we dont mutate it for other nodes
const c = { ...this.groupData.nodeData };
c.nodes = [...c.nodes];
const innerNodes = this.node.getInnerNodes();
let ids = [];
for (let i = 0; i < c.nodes.length; i++) {
let id = innerNodes?.[i]?.id;
// Use existing IDs if they are set on the inner nodes
if (id == null || isNaN(id)) {
id = undefined;
} else {
ids.push(id);
}
c.nodes[i] = { ...c.nodes[i], id };
}
localStorage.setItem("litegrapheditor_clipboard", JSON.stringify(c));
app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", backup);
const [x, y] = this.node.pos;
let top;
let left;
// Configure nodes with current widget data
const selectedIds = ids.length ? ids : Object.keys(app.canvas.selected_nodes);
const newNodes = [];
for (let i = 0; i < selectedIds.length; i++) {
const id = selectedIds[i];
const newNode = app.graph.getNodeById(id);
const innerNode = innerNodes[i];
newNodes.push(newNode);
if (left == null || newNode.pos[0] < left) {
left = newNode.pos[0];
}
if (top == null || newNode.pos[1] < top) {
top = newNode.pos[1];
}
const map = this.groupData.oldToNewWidgetMap[innerNode.index];
if (map) {
const widgets = Object.keys(map);
for (const oldName of widgets) {
const newName = map[oldName];
if (!newName) continue;
const widgetIndex = this.node.widgets.findIndex((w) => w.name === newName);
if (widgetIndex === -1) continue;
// Populate the main and any linked widgets
if (innerNode.type === "PrimitiveNode") {
for (let i = 0; i < newNode.widgets.length; i++) {
newNode.widgets[i].value = this.node.widgets[widgetIndex + i].value;
}
} else {
const outerWidget = this.node.widgets[widgetIndex];
const newWidget = newNode.widgets.find((w) => w.name === oldName);
if (!newWidget) continue;
newWidget.value = outerWidget.value;
for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
newWidget.linkedWidgets[w].value = outerWidget.linkedWidgets[w].value;
}
}
}
}
}
// Shift each node
for (const newNode of newNodes) {
newNode.pos = [newNode.pos[0] - (left - x), newNode.pos[1] - (top - y)];
}
return { newNodes, selectedIds };
};
const reconnectInputs = (selectedIds) => {
for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
const id = selectedIds[innerNodeIndex];
const newNode = app.graph.getNodeById(id);
const map = this.groupData.oldToNewInputMap[innerNodeIndex];
for (const innerInputId in map) {
const groupSlotId = map[innerInputId];
if (groupSlotId == null) continue;
const slot = node.inputs[groupSlotId];
if (slot.link == null) continue;
const link = app.graph.links[slot.link];
// connect this node output to the input of another node
const originNode = app.graph.getNodeById(link.origin_id);
originNode.connect(link.origin_slot, newNode, +innerInputId);
}
}
};
const reconnectOutputs = () => {
for (let groupOutputId = 0; groupOutputId < node.outputs?.length; groupOutputId++) {
const output = node.outputs[groupOutputId];
if (!output.links) continue;
const links = [...output.links];
for (const l of links) {
const slot = this.groupData.newToOldOutputMap[groupOutputId];
const link = app.graph.links[l];
const targetNode = app.graph.getNodeById(link.target_id);
const newNode = app.graph.getNodeById(selectedIds[slot.node.index]);
newNode.connect(slot.slot, targetNode, link.target_slot);
}
}
};
const { newNodes, selectedIds } = addInnerNodes();
reconnectInputs(selectedIds);
reconnectOutputs(selectedIds);
app.graph.remove(this.node);
return newNodes;
};
const getExtraMenuOptions = this.node.getExtraMenuOptions;
this.node.getExtraMenuOptions = function (_, options) {
getExtraMenuOptions?.apply(this, arguments);
let optionIndex = options.findIndex((o) => o.content === "Outputs");
if (optionIndex === -1) optionIndex = options.length;
else optionIndex++;
options.splice(optionIndex, 0, null, {
content: "Convert to nodes",
callback: () => {
return this.convertToNodes();
},
});
};
// Draw custom collapse icon to identity this as a group
const onDrawTitleBox = this.node.onDrawTitleBox;
this.node.onDrawTitleBox = function (ctx, height, size, scale) {
onDrawTitleBox?.apply(this, arguments);
const fill = ctx.fillStyle;
ctx.beginPath();
ctx.rect(11, -height + 11, 2, 2);
ctx.rect(14, -height + 11, 2, 2);
ctx.rect(17, -height + 11, 2, 2);
ctx.rect(11, -height + 14, 2, 2);
ctx.rect(14, -height + 14, 2, 2);
ctx.rect(17, -height + 14, 2, 2);
ctx.rect(11, -height + 17, 2, 2);
ctx.rect(14, -height + 17, 2, 2);
ctx.rect(17, -height + 17, 2, 2);
ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR;
ctx.fill();
ctx.fillStyle = fill;
};
// Draw progress label
const onDrawForeground = node.onDrawForeground;
const groupData = this.groupData.nodeData;
node.onDrawForeground = function (ctx) {
const r = onDrawForeground?.apply?.(this, arguments);
if (+app.runningNodeId === this.id && this.runningInternalNodeId !== null) {
const n = groupData.nodes[this.runningInternalNodeId];
const message = `
Running
$
{
n
.
title
||
n
.
type
}
(
$
{
this
.
runningInternalNodeId
}
/${groupData.nodes.length}
)
`
;
ctx
.
save
();
ctx
.
font
=
"
12px sans-serif
"
;
const
sz
=
ctx
.
measureText
(
message
);
ctx
.
fillStyle
=
node
.
boxcolor
||
LiteGraph
.
NODE_DEFAULT_BOXCOLOR
;
ctx
.
beginPath
();
ctx
.
roundRect
(
0
,
-
LiteGraph
.
NODE_TITLE_HEIGHT
-
20
,
sz
.
width
+
12
,
20
,
5
);
ctx
.
fill
();
ctx
.
fillStyle
=
"
#fff
"
;
ctx
.
fillText
(
message
,
6
,
-
LiteGraph
.
NODE_TITLE_HEIGHT
-
6
);
ctx
.
restore
();
}
};
// Flag this node as needing to be reset
const
onExecutionStart
=
this
.
node
.
onExecutionStart
;
this
.
node
.
onExecutionStart
=
function
()
{
this
.
resetExecution
=
true
;
return
onExecutionStart
?.
apply
(
this
,
arguments
);
};
function
handleEvent
(
type
,
getId
,
getEvent
)
{
const
handler
=
({
detail
})
=>
{
const
id
=
getId
(
detail
);
if
(
!
id
)
return
;
const
node
=
app
.
graph
.
getNodeById
(
id
);
if
(
node
)
return
;
const
innerNodeIndex
=
this
.
innerNodes
?.
findIndex
((
n
)
=>
n
.
id
==
id
);
if
(
innerNodeIndex
>
-
1
)
{
this
.
node
.
runningInternalNodeId
=
innerNodeIndex
;
api
.
dispatchEvent
(
new
CustomEvent
(
type
,
{
detail
:
getEvent
(
detail
,
this
.
node
.
id
+
""
,
this
.
node
)
}));
}
};
api
.
addEventListener
(
type
,
handler
);
return
handler
;
}
const
executing
=
handleEvent
.
call
(
this
,
"
executing
"
,
(
d
)
=>
d
,
(
d
,
id
,
node
)
=>
id
);
const
executed
=
handleEvent
.
call
(
this
,
"
executed
"
,
(
d
)
=>
d
?.
node
,
(
d
,
id
,
node
)
=>
({
...
d
,
node
:
id
,
merge
:
!
node
.
resetExecution
})
);
const
onRemoved
=
node
.
onRemoved
;
this
.
node
.
onRemoved
=
function
()
{
onRemoved
?.
apply
(
this
,
arguments
);
api
.
removeEventListener
(
"
executing
"
,
executing
);
api
.
removeEventListener
(
"
executed
"
,
executed
);
};
}
updateInnerWidgets
()
{
for
(
const
newWidgetName
in
this
.
groupData
.
newToOldWidgetMap
)
{
const
newWidget
=
this
.
node
.
widgets
.
find
((
w
)
=>
w
.
name
===
newWidgetName
);
if
(
!
newWidget
)
continue
;
const
newValue
=
newWidget
.
value
;
const
old
=
this
.
groupData
.
newToOldWidgetMap
[
newWidgetName
];
let
innerNode
=
this
.
innerNodes
[
old
.
node
.
index
];
if
(
innerNode
.
type
===
"
PrimitiveNode
"
)
{
innerNode
.
primitiveValue
=
newValue
;
const
primitiveLinked
=
this
.
groupData
.
primitiveToWidget
[
old
.
node
.
index
];
for
(
const
linked
of
primitiveLinked
)
{
const
node
=
this
.
innerNodes
[
linked
.
nodeId
];
const
widget
=
node
.
widgets
.
find
((
w
)
=>
w
.
name
===
linked
.
inputName
);
if
(
widget
)
{
widget
.
value
=
newValue
;
}
}
continue
;
}
const
widget
=
innerNode
.
widgets
?.
find
((
w
)
=>
w
.
name
===
old
.
inputName
);
if
(
widget
)
{
widget
.
value
=
newValue
;
}
}
}
populatePrimitive
(
node
,
nodeId
,
oldName
,
i
,
linkedShift
)
{
// Converted widget, populate primitive if linked
const
primitiveId
=
this
.
groupData
.
widgetToPrimitive
[
nodeId
]?.[
oldName
];
if
(
primitiveId
==
null
)
return
;
const
targetWidgetName
=
this
.
groupData
.
oldToNewWidgetMap
[
primitiveId
][
"
value
"
];
const
targetWidgetIndex
=
this
.
node
.
widgets
.
findIndex
((
w
)
=>
w
.
name
===
targetWidgetName
);
if
(
targetWidgetIndex
>
-
1
)
{
const
primitiveNode
=
this
.
innerNodes
[
primitiveId
];
let
len
=
primitiveNode
.
widgets
.
length
;
if
(
len
-
1
!==
this
.
node
.
widgets
[
targetWidgetIndex
].
linkedWidgets
?.
length
)
{
// Fallback handling for if some reason the primitive has a different number of widgets
// we dont want to overwrite random widgets, better to leave blank
len
=
1
;
}
for
(
let
i
=
0
;
i
<
len
;
i
++
)
{
this
.
node
.
widgets
[
targetWidgetIndex
+
i
].
value
=
primitiveNode
.
widgets
[
i
].
value
;
}
}
}
populateWidgets
()
{
for
(
let
nodeId
=
0
;
nodeId
<
this
.
groupData
.
nodeData
.
nodes
.
length
;
nodeId
++
)
{
const
node
=
this
.
groupData
.
nodeData
.
nodes
[
nodeId
];
if
(
!
node
.
widgets_values
?.
length
)
continue
;
const
map
=
this
.
groupData
.
oldToNewWidgetMap
[
nodeId
];
const
widgets
=
Object
.
keys
(
map
);
let
linkedShift
=
0
;
for
(
let
i
=
0
;
i
<
widgets
.
length
;
i
++
)
{
const
oldName
=
widgets
[
i
];
const
newName
=
map
[
oldName
];
const
widgetIndex
=
this
.
node
.
widgets
.
findIndex
((
w
)
=>
w
.
name
===
newName
);
const
mainWidget
=
this
.
node
.
widgets
[
widgetIndex
];
if
(
!
newName
)
{
// New name will be null if its a converted widget
this
.
populatePrimitive
(
node
,
nodeId
,
oldName
,
i
,
linkedShift
);
// Find the inner widget and shift by the number of linked widgets as they will have been removed too
const
innerWidget
=
this
.
innerNodes
[
nodeId
].
widgets
?.
find
((
w
)
=>
w
.
name
===
oldName
);
linkedShift
+=
innerWidget
.
linkedWidgets
?.
length
??
0
;
continue
;
}
if
(
widgetIndex
===
-
1
)
{
continue
;
}
// Populate the main and any linked widget
mainWidget
.
value
=
node
.
widgets_values
[
i
+
linkedShift
];
for
(
let
w
=
0
;
w
<
mainWidget
.
linkedWidgets
?.
length
;
w
++
)
{
this
.
node
.
widgets
[
widgetIndex
+
w
+
1
].
value
=
node
.
widgets_values
[
i
+
++
linkedShift
];
}
}
}
}
replaceNodes
(
nodes
)
{
let
top
;
let
left
;
for
(
let
i
=
0
;
i
<
nodes
.
length
;
i
++
)
{
const
node
=
nodes
[
i
];
if
(
left
==
null
||
node
.
pos
[
0
]
<
left
)
{
left
=
node
.
pos
[
0
];
}
if
(
top
==
null
||
node
.
pos
[
1
]
<
top
)
{
top
=
node
.
pos
[
1
];
}
this
.
linkOutputs
(
node
,
i
);
app
.
graph
.
remove
(
node
);
}
this
.
linkInputs
();
this
.
node
.
pos
=
[
left
,
top
];
}
linkOutputs
(
originalNode
,
nodeId
)
{
if
(
!
originalNode
.
outputs
)
return
;
for
(
const
output
of
originalNode
.
outputs
)
{
if
(
!
output
.
links
)
continue
;
// Clone the links as they'll be changed if we reconnect
const
links
=
[...
output
.
links
];
for
(
const
l
of
links
)
{
const
link
=
app
.
graph
.
links
[
l
];
if
(
!
link
)
continue
;
const
targetNode
=
app
.
graph
.
getNodeById
(
link
.
target_id
);
const
newSlot
=
this
.
groupData
.
oldToNewOutputMap
[
nodeId
]?.[
link
.
origin_slot
];
if
(
newSlot
!=
null
)
{
this
.
node
.
connect
(
newSlot
,
targetNode
,
link
.
target_slot
);
}
}
}
}
linkInputs
()
{
for
(
const
link
of
this
.
groupData
.
nodeData
.
links
??
[])
{
const
[,
originSlot
,
targetId
,
targetSlot
,
actualOriginId
]
=
link
;
const
originNode
=
app
.
graph
.
getNodeById
(
actualOriginId
);
if
(
!
originNode
)
continue
;
// this node is in the group
originNode
.
connect
(
originSlot
,
this
.
node
.
id
,
this
.
groupData
.
oldToNewInputMap
[
targetId
][
targetSlot
]);
}
}
static
getGroupData
(
node
)
{
return
node
.
constructor
?.
nodeData
?.[
GROUP
];
}
static
isGroupNode
(
node
)
{
return
!!
node
.
constructor
?.
nodeData
?.[
GROUP
];
}
static
async
fromNodes
(
nodes
)
{
// Process the nodes into the stored workflow group node data
const
builder
=
new
GroupNodeBuilder
(
nodes
);
const
res
=
builder
.
build
();
if
(
!
res
)
return
;
const
{
name
,
nodeData
}
=
res
;
// Convert this data into a LG node definition and register it
const
config
=
new
GroupNodeConfig
(
name
,
nodeData
);
await
config
.
registerType
();
const
groupNode
=
LiteGraph
.
createNode
(
`workflow/
${
name
}
`
);
// Reuse the existing nodes for this instance
groupNode
.
setInnerNodes
(
builder
.
nodes
);
groupNode
[
GROUP
].
populateWidgets
();
app
.
graph
.
add
(
groupNode
);
// Remove all converted nodes and relink them
groupNode
[
GROUP
].
replaceNodes
(
builder
.
nodes
);
return
groupNode
;
}
}
function
addConvertToGroupOptions
()
{
function
addOption
(
options
,
index
)
{
const
selected
=
Object
.
values
(
app
.
canvas
.
selected_nodes
??
{});
const
disabled
=
selected
.
length
<
2
||
selected
.
find
((
n
)
=>
GroupNodeHandler
.
isGroupNode
(
n
));
options
.
splice
(
index
+
1
,
null
,
{
content
:
`Convert to Group Node`
,
disabled
,
callback
:
async
()
=>
{
return
await
GroupNodeHandler
.
fromNodes
(
selected
);
},
});
}
// Add to canvas
const
getCanvasMenuOptions
=
LGraphCanvas
.
prototype
.
getCanvasMenuOptions
;
LGraphCanvas
.
prototype
.
getCanvasMenuOptions
=
function
()
{
const
options
=
getCanvasMenuOptions
.
apply
(
this
,
arguments
);
const
index
=
options
.
findIndex
((
o
)
=>
o
?.
content
===
"
Add Group
"
)
+
1
||
options
.
length
;
addOption
(
options
,
index
);
return
options
;
};
// Add to nodes
const
getNodeMenuOptions
=
LGraphCanvas
.
prototype
.
getNodeMenuOptions
;
LGraphCanvas
.
prototype
.
getNodeMenuOptions
=
function
(
node
)
{
const
options
=
getNodeMenuOptions
.
apply
(
this
,
arguments
);
if
(
!
GroupNodeHandler
.
isGroupNode
(
node
))
{
const
index
=
options
.
findIndex
((
o
)
=>
o
?.
content
===
"
Outputs
"
)
+
1
||
options
.
length
-
1
;
addOption
(
options
,
index
);
}
return
options
;
};
}
const
id
=
"
Comfy.GroupNode
"
;
let
globalDefs
;
const
ext
=
{
name
:
id
,
setup
()
{
addConvertToGroupOptions
();
},
async
beforeConfigureGraph
(
graphData
,
missingNodeTypes
)
{
const
nodes
=
graphData
?.
extra
?.
groupNodes
;
if
(
nodes
)
{
await
GroupNodeConfig
.
registerFromWorkflow
(
nodes
,
missingNodeTypes
);
}
},
addCustomNodeDefs
(
defs
)
{
// Store this so we can mutate it later with group nodes
globalDefs
=
defs
;
},
nodeCreated
(
node
)
{
if
(
GroupNodeHandler
.
isGroupNode
(
node
))
{
node
[
GROUP
]
=
new
GroupNodeHandler
(
node
);
}
},
};
app
.
registerExtension
(
ext
);
web/extensions/core/nodeTemplates.js
View file @
c92f3dca
import
{
app
}
from
"
../../scripts/app.js
"
;
import
{
ComfyDialog
,
$el
}
from
"
../../scripts/ui.js
"
;
import
{
GroupNodeConfig
,
GroupNodeHandler
}
from
"
./groupNode.js
"
;
// Adds the ability to save and add multiple nodes as a template
// To save:
...
...
@@ -34,7 +35,7 @@ class ManageTemplates extends ComfyDialog {
type
:
"
file
"
,
accept
:
"
.json
"
,
multiple
:
true
,
style
:
{
display
:
"
none
"
},
style
:
{
display
:
"
none
"
},
parent
:
document
.
body
,
onchange
:
()
=>
this
.
importAll
(),
});
...
...
@@ -109,13 +110,13 @@ class ManageTemplates extends ComfyDialog {
return
;
}
const
json
=
JSON
.
stringify
({
templates
:
this
.
templates
},
null
,
2
);
// convert the data to a JSON string
const
blob
=
new
Blob
([
json
],
{
type
:
"
application/json
"
});
const
json
=
JSON
.
stringify
({
templates
:
this
.
templates
},
null
,
2
);
// convert the data to a JSON string
const
blob
=
new
Blob
([
json
],
{
type
:
"
application/json
"
});
const
url
=
URL
.
createObjectURL
(
blob
);
const
a
=
$el
(
"
a
"
,
{
href
:
url
,
download
:
"
node_templates.json
"
,
style
:
{
display
:
"
none
"
},
style
:
{
display
:
"
none
"
},
parent
:
document
.
body
,
});
a
.
click
();
...
...
@@ -291,11 +292,11 @@ app.registerExtension({
setup
()
{
const
manage
=
new
ManageTemplates
();
const
clipboardAction
=
(
cb
)
=>
{
const
clipboardAction
=
async
(
cb
)
=>
{
// We use the clipboard functions but dont want to overwrite the current user clipboard
// Restore it after we've run our callback
const
old
=
localStorage
.
getItem
(
"
litegrapheditor_clipboard
"
);
cb
();
await
cb
();
localStorage
.
setItem
(
"
litegrapheditor_clipboard
"
,
old
);
};
...
...
@@ -309,13 +310,31 @@ app.registerExtension({
disabled
:
!
Object
.
keys
(
app
.
canvas
.
selected_nodes
||
{}).
length
,
callback
:
()
=>
{
const
name
=
prompt
(
"
Enter name
"
);
if
(
!
name
||
!
name
.
trim
())
return
;
if
(
!
name
?
.
trim
())
return
;
clipboardAction
(()
=>
{
app
.
canvas
.
copyToClipboard
();
let
data
=
localStorage
.
getItem
(
"
litegrapheditor_clipboard
"
);
data
=
JSON
.
parse
(
data
);
const
nodeIds
=
Object
.
keys
(
app
.
canvas
.
selected_nodes
);
for
(
let
i
=
0
;
i
<
nodeIds
.
length
;
i
++
)
{
const
node
=
app
.
graph
.
getNodeById
(
nodeIds
[
i
]);
const
nodeData
=
node
?.
constructor
.
nodeData
;
let
groupData
=
GroupNodeHandler
.
getGroupData
(
node
);
if
(
groupData
)
{
groupData
=
groupData
.
nodeData
;
if
(
!
data
.
groupNodes
)
{
data
.
groupNodes
=
{};
}
data
.
groupNodes
[
nodeData
.
name
]
=
groupData
;
data
.
nodes
[
i
].
type
=
nodeData
.
name
;
}
}
manage
.
templates
.
push
({
name
,
data
:
localStorage
.
getItem
(
"
litegrapheditor_clipboard
"
),
data
:
JSON
.
stringify
(
data
),
});
manage
.
store
();
});
...
...
@@ -323,15 +342,19 @@ app.registerExtension({
});
// Map each template to a menu item
const
subItems
=
manage
.
templates
.
map
((
t
)
=>
({
content
:
t
.
name
,
callback
:
()
=>
{
clipboardAction
(()
=>
{
localStorage
.
setItem
(
"
litegrapheditor_clipboard
"
,
t
.
data
);
app
.
canvas
.
pasteFromClipboard
();
});
},
}));
const
subItems
=
manage
.
templates
.
map
((
t
)
=>
{
return
{
content
:
t
.
name
,
callback
:
()
=>
{
clipboardAction
(
async
()
=>
{
const
data
=
JSON
.
parse
(
t
.
data
);
await
GroupNodeConfig
.
registerFromWorkflow
(
data
.
groupNodes
,
{});
localStorage
.
setItem
(
"
litegrapheditor_clipboard
"
,
t
.
data
);
app
.
canvas
.
pasteFromClipboard
();
});
},
};
});
subItems
.
push
(
null
,
{
content
:
"
Manage
"
,
...
...
web/extensions/core/undoRedo.js
0 → 100644
View file @
c92f3dca
import
{
app
}
from
"
../../scripts/app.js
"
;
const
MAX_HISTORY
=
50
;
let
undo
=
[];
let
redo
=
[];
let
activeState
=
null
;
let
isOurLoad
=
false
;
function
checkState
()
{
const
currentState
=
app
.
graph
.
serialize
();
if
(
!
graphEqual
(
activeState
,
currentState
))
{
undo
.
push
(
activeState
);
if
(
undo
.
length
>
MAX_HISTORY
)
{
undo
.
shift
();
}
activeState
=
clone
(
currentState
);
redo
.
length
=
0
;
}
}
const
loadGraphData
=
app
.
loadGraphData
;
app
.
loadGraphData
=
async
function
()
{
const
v
=
await
loadGraphData
.
apply
(
this
,
arguments
);
if
(
isOurLoad
)
{
isOurLoad
=
false
;
}
else
{
checkState
();
}
return
v
;
};
function
clone
(
obj
)
{
try
{
if
(
typeof
structuredClone
!==
"
undefined
"
)
{
return
structuredClone
(
obj
);
}
}
catch
(
error
)
{
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return
JSON
.
parse
(
JSON
.
stringify
(
obj
));
}
function
graphEqual
(
a
,
b
,
root
=
true
)
{
if
(
a
===
b
)
return
true
;
if
(
typeof
a
==
"
object
"
&&
a
&&
typeof
b
==
"
object
"
&&
b
)
{
const
keys
=
Object
.
getOwnPropertyNames
(
a
);
if
(
keys
.
length
!=
Object
.
getOwnPropertyNames
(
b
).
length
)
{
return
false
;
}
for
(
const
key
of
keys
)
{
let
av
=
a
[
key
];
let
bv
=
b
[
key
];
if
(
root
&&
key
===
"
nodes
"
)
{
// Nodes need to be sorted as the order changes when selecting nodes
av
=
[...
av
].
sort
((
a
,
b
)
=>
a
.
id
-
b
.
id
);
bv
=
[...
bv
].
sort
((
a
,
b
)
=>
a
.
id
-
b
.
id
);
}
if
(
!
graphEqual
(
av
,
bv
,
false
))
{
return
false
;
}
}
return
true
;
}
return
false
;
}
const
undoRedo
=
async
(
e
)
=>
{
if
(
e
.
ctrlKey
||
e
.
metaKey
)
{
if
(
e
.
key
===
"
y
"
)
{
const
prevState
=
redo
.
pop
();
if
(
prevState
)
{
undo
.
push
(
activeState
);
isOurLoad
=
true
;
await
app
.
loadGraphData
(
prevState
);
activeState
=
prevState
;
}
return
true
;
}
else
if
(
e
.
key
===
"
z
"
)
{
const
prevState
=
undo
.
pop
();
if
(
prevState
)
{
redo
.
push
(
activeState
);
isOurLoad
=
true
;
await
app
.
loadGraphData
(
prevState
);
activeState
=
prevState
;
}
return
true
;
}
}
};
const
bindInput
=
(
activeEl
)
=>
{
if
(
activeEl
?.
tagName
!==
"
CANVAS
"
&&
activeEl
?.
tagName
!==
"
BODY
"
)
{
for
(
const
evt
of
[
"
change
"
,
"
input
"
,
"
blur
"
])
{
if
(
`on
${
evt
}
`
in
activeEl
)
{
const
listener
=
()
=>
{
checkState
();
activeEl
.
removeEventListener
(
evt
,
listener
);
};
activeEl
.
addEventListener
(
evt
,
listener
);
return
true
;
}
}
}
};
window
.
addEventListener
(
"
keydown
"
,
(
e
)
=>
{
requestAnimationFrame
(
async
()
=>
{
const
activeEl
=
document
.
activeElement
;
if
(
activeEl
?.
tagName
===
"
INPUT
"
||
activeEl
?.
type
===
"
textarea
"
)
{
// Ignore events on inputs, they have their native history
return
;
}
// Check if this is a ctrl+z ctrl+y
if
(
await
undoRedo
(
e
))
return
;
// If our active element is some type of input then handle changes after they're done
if
(
bindInput
(
activeEl
))
return
;
checkState
();
});
},
true
);
// Handle clicking DOM elements (e.g. widgets)
window
.
addEventListener
(
"
mouseup
"
,
()
=>
{
checkState
();
});
// Handle litegraph clicks
const
processMouseUp
=
LGraphCanvas
.
prototype
.
processMouseUp
;
LGraphCanvas
.
prototype
.
processMouseUp
=
function
(
e
)
{
const
v
=
processMouseUp
.
apply
(
this
,
arguments
);
checkState
();
return
v
;
};
const
processMouseDown
=
LGraphCanvas
.
prototype
.
processMouseDown
;
LGraphCanvas
.
prototype
.
processMouseDown
=
function
(
e
)
{
const
v
=
processMouseDown
.
apply
(
this
,
arguments
);
checkState
();
return
v
;
};
web/extensions/core/widgetInputs.js
View file @
c92f3dca
import
{
ComfyWidgets
,
addValueControlWidget
}
from
"
../../scripts/widgets.js
"
;
import
{
ComfyWidgets
,
addValueControlWidget
s
}
from
"
../../scripts/widgets.js
"
;
import
{
app
}
from
"
../../scripts/app.js
"
;
const
CONVERTED_TYPE
=
"
converted-widget
"
;
...
...
@@ -121,6 +121,110 @@ function isValidCombo(combo, obj) {
return
true
;
}
export
function
mergeIfValid
(
output
,
config2
,
forceUpdate
,
recreateWidget
,
config1
)
{
if
(
!
config1
)
{
config1
=
output
.
widget
[
CONFIG
]
??
output
.
widget
[
GET_CONFIG
]();
}
if
(
config1
[
0
]
instanceof
Array
)
{
if
(
!
isValidCombo
(
config1
[
0
],
config2
[
0
]))
return
false
;
}
else
if
(
config1
[
0
]
!==
config2
[
0
])
{
// Types dont match
console
.
log
(
`connection rejected: types dont match`
,
config1
[
0
],
config2
[
0
]);
return
false
;
}
const
keys
=
new
Set
([...
Object
.
keys
(
config1
[
1
]
??
{}),
...
Object
.
keys
(
config2
[
1
]
??
{})]);
let
customConfig
;
const
getCustomConfig
=
()
=>
{
if
(
!
customConfig
)
{
if
(
typeof
structuredClone
===
"
undefined
"
)
{
customConfig
=
JSON
.
parse
(
JSON
.
stringify
(
config1
[
1
]
??
{}));
}
else
{
customConfig
=
structuredClone
(
config1
[
1
]
??
{});
}
}
return
customConfig
;
};
const
isNumber
=
config1
[
0
]
===
"
INT
"
||
config1
[
0
]
===
"
FLOAT
"
;
for
(
const
k
of
keys
.
values
())
{
if
(
k
!==
"
default
"
&&
k
!==
"
forceInput
"
&&
k
!==
"
defaultInput
"
)
{
let
v1
=
config1
[
1
][
k
];
let
v2
=
config2
[
1
]?.[
k
];
if
(
v1
===
v2
||
(
!
v1
&&
!
v2
))
continue
;
if
(
isNumber
)
{
if
(
k
===
"
min
"
)
{
const
theirMax
=
config2
[
1
]?.[
"
max
"
];
if
(
theirMax
!=
null
&&
v1
>
theirMax
)
{
console
.
log
(
"
connection rejected: min > max
"
,
v1
,
theirMax
);
return
false
;
}
getCustomConfig
()[
k
]
=
v1
==
null
?
v2
:
v2
==
null
?
v1
:
Math
.
max
(
v1
,
v2
);
continue
;
}
else
if
(
k
===
"
max
"
)
{
const
theirMin
=
config2
[
1
]?.[
"
min
"
];
if
(
theirMin
!=
null
&&
v1
<
theirMin
)
{
console
.
log
(
"
connection rejected: max < min
"
,
v1
,
theirMin
);
return
false
;
}
getCustomConfig
()[
k
]
=
v1
==
null
?
v2
:
v2
==
null
?
v1
:
Math
.
min
(
v1
,
v2
);
continue
;
}
else
if
(
k
===
"
step
"
)
{
let
step
;
if
(
v1
==
null
)
{
// No current step
step
=
v2
;
}
else
if
(
v2
==
null
)
{
// No new step
step
=
v1
;
}
else
{
if
(
v1
<
v2
)
{
// Ensure v1 is larger for the mod
const
a
=
v2
;
v2
=
v1
;
v1
=
a
;
}
if
(
v1
%
v2
)
{
console
.
log
(
"
connection rejected: steps not divisible
"
,
"
current:
"
,
v1
,
"
new:
"
,
v2
);
return
false
;
}
step
=
v1
;
}
getCustomConfig
()[
k
]
=
step
;
continue
;
}
}
console
.
log
(
`connection rejected: config
${
k
}
values dont match`
,
v1
,
v2
);
return
false
;
}
}
if
(
customConfig
||
forceUpdate
)
{
if
(
customConfig
)
{
output
.
widget
[
CONFIG
]
=
[
config1
[
0
],
customConfig
];
}
const
widget
=
recreateWidget
?.
call
(
this
);
// When deleting a node this can be null
if
(
widget
)
{
const
min
=
widget
.
options
.
min
;
const
max
=
widget
.
options
.
max
;
if
(
min
!=
null
&&
widget
.
value
<
min
)
widget
.
value
=
min
;
if
(
max
!=
null
&&
widget
.
value
>
max
)
widget
.
value
=
max
;
widget
.
callback
(
widget
.
value
);
}
}
return
{
customConfig
};
}
app
.
registerExtension
({
name
:
"
Comfy.WidgetInputs
"
,
async
beforeRegisterNodeDef
(
nodeType
,
nodeData
,
app
)
{
...
...
@@ -308,7 +412,7 @@ app.registerExtension({
this
.
isVirtualNode
=
true
;
}
applyToGraph
()
{
applyToGraph
(
extraLinks
=
[]
)
{
if
(
!
this
.
outputs
[
0
].
links
?.
length
)
return
;
function
get_links
(
node
)
{
...
...
@@ -325,10 +429,9 @@ app.registerExtension({
return
links
;
}
let
links
=
get_links
(
this
);
let
links
=
[...
get_links
(
this
)
.
map
((
l
)
=>
app
.
graph
.
links
[
l
]),
...
extraLinks
]
;
// For each output link copy our value over the original widget value
for
(
const
l
of
links
)
{
const
linkInfo
=
app
.
graph
.
links
[
l
];
for
(
const
linkInfo
of
links
)
{
const
node
=
this
.
graph
.
getNodeById
(
linkInfo
.
target_id
);
const
input
=
node
.
inputs
[
linkInfo
.
target_slot
];
const
widgetName
=
input
.
widget
.
name
;
...
...
@@ -405,7 +508,12 @@ app.registerExtension({
}
if
(
this
.
outputs
[
slot
].
links
?.
length
)
{
return
this
.
#
isValidConnection
(
input
);
const
valid
=
this
.
#
isValidConnection
(
input
);
if
(
valid
)
{
// On connect of additional outputs, copy our value to their widget
this
.
applyToGraph
([{
target_id
:
target_node
.
id
,
target_slot
}]);
}
return
valid
;
}
}
...
...
@@ -462,12 +570,16 @@ app.registerExtension({
}
}
if
(
widget
.
type
===
"
number
"
||
widget
.
type
===
"
combo
"
)
{
if
(
!
inputData
?.[
1
]?.
control_after_generate
&&
(
widget
.
type
===
"
number
"
||
widget
.
type
===
"
combo
"
)
)
{
let
control_value
=
this
.
widgets_values
?.[
1
];
if
(
!
control_value
)
{
control_value
=
"
fixed
"
;
}
addValueControlWidget
(
this
,
widget
,
control_value
);
addValueControlWidgets
(
this
,
widget
,
control_value
,
undefined
,
inputData
);
let
filter
=
this
.
widgets_values
?.[
2
];
if
(
filter
&&
this
.
widgets
.
length
===
3
)
{
this
.
widgets
[
2
].
value
=
filter
;
}
}
// When our value changes, update other widgets to reflect our changes
...
...
@@ -503,6 +615,7 @@ app.registerExtension({
this
.
#
removeWidgets
();
this
.
#
onFirstConnection
(
true
);
for
(
let
i
=
0
;
i
<
this
.
widgets
?.
length
;
i
++
)
this
.
widgets
[
i
].
value
=
values
[
i
];
return
this
.
widgets
[
0
];
}
#
mergeWidgetConfig
()
{
...
...
@@ -543,108 +656,8 @@ app.registerExtension({
#
isValidConnection
(
input
,
forceUpdate
)
{
// Only allow connections where the configs match
const
output
=
this
.
outputs
[
0
];
const
config1
=
output
.
widget
[
CONFIG
]
??
output
.
widget
[
GET_CONFIG
]();
const
config2
=
input
.
widget
[
GET_CONFIG
]();
if
(
config1
[
0
]
instanceof
Array
)
{
if
(
!
isValidCombo
(
config1
[
0
],
config2
[
0
]))
return
false
;
}
else
if
(
config1
[
0
]
!==
config2
[
0
])
{
// Types dont match
console
.
log
(
`connection rejected: types dont match`
,
config1
[
0
],
config2
[
0
]);
return
false
;
}
const
keys
=
new
Set
([...
Object
.
keys
(
config1
[
1
]
??
{}),
...
Object
.
keys
(
config2
[
1
]
??
{})]);
let
customConfig
;
const
getCustomConfig
=
()
=>
{
if
(
!
customConfig
)
{
if
(
typeof
structuredClone
===
"
undefined
"
)
{
customConfig
=
JSON
.
parse
(
JSON
.
stringify
(
config1
[
1
]
??
{}));
}
else
{
customConfig
=
structuredClone
(
config1
[
1
]
??
{});
}
}
return
customConfig
;
};
const
isNumber
=
config1
[
0
]
===
"
INT
"
||
config1
[
0
]
===
"
FLOAT
"
;
for
(
const
k
of
keys
.
values
())
{
if
(
k
!==
"
default
"
&&
k
!==
"
forceInput
"
&&
k
!==
"
defaultInput
"
)
{
let
v1
=
config1
[
1
][
k
];
let
v2
=
config2
[
1
][
k
];
if
(
v1
===
v2
||
(
!
v1
&&
!
v2
))
continue
;
if
(
isNumber
)
{
if
(
k
===
"
min
"
)
{
const
theirMax
=
config2
[
1
][
"
max
"
];
if
(
theirMax
!=
null
&&
v1
>
theirMax
)
{
console
.
log
(
"
connection rejected: min > max
"
,
v1
,
theirMax
);
return
false
;
}
getCustomConfig
()[
k
]
=
v1
==
null
?
v2
:
v2
==
null
?
v1
:
Math
.
max
(
v1
,
v2
);
continue
;
}
else
if
(
k
===
"
max
"
)
{
const
theirMin
=
config2
[
1
][
"
min
"
];
if
(
theirMin
!=
null
&&
v1
<
theirMin
)
{
console
.
log
(
"
connection rejected: max < min
"
,
v1
,
theirMin
);
return
false
;
}
getCustomConfig
()[
k
]
=
v1
==
null
?
v2
:
v2
==
null
?
v1
:
Math
.
min
(
v1
,
v2
);
continue
;
}
else
if
(
k
===
"
step
"
)
{
let
step
;
if
(
v1
==
null
)
{
// No current step
step
=
v2
;
}
else
if
(
v2
==
null
)
{
// No new step
step
=
v1
;
}
else
{
if
(
v1
<
v2
)
{
// Ensure v1 is larger for the mod
const
a
=
v2
;
v2
=
v1
;
v1
=
a
;
}
if
(
v1
%
v2
)
{
console
.
log
(
"
connection rejected: steps not divisible
"
,
"
current:
"
,
v1
,
"
new:
"
,
v2
);
return
false
;
}
step
=
v1
;
}
getCustomConfig
()[
k
]
=
step
;
continue
;
}
}
console
.
log
(
`connection rejected: config
${
k
}
values dont match`
,
v1
,
v2
);
return
false
;
}
}
if
(
customConfig
||
forceUpdate
)
{
if
(
customConfig
)
{
output
.
widget
[
CONFIG
]
=
[
config1
[
0
],
customConfig
];
}
this
.
#
recreateWidget
();
const
widget
=
this
.
widgets
[
0
];
// When deleting a node this can be null
if
(
widget
)
{
const
min
=
widget
.
options
.
min
;
const
max
=
widget
.
options
.
max
;
if
(
min
!=
null
&&
widget
.
value
<
min
)
widget
.
value
=
min
;
if
(
max
!=
null
&&
widget
.
value
>
max
)
widget
.
value
=
max
;
widget
.
callback
(
widget
.
value
);
}
}
return
true
;
return
!!
mergeIfValid
.
call
(
this
,
output
,
config2
,
forceUpdate
,
this
.
#
recreateWidget
);
}
#
removeWidgets
()
{
...
...
web/lib/litegraph.core.js
View file @
c92f3dca
...
...
@@ -2533,7 +2533,7 @@
var
w
=
this
.
widgets
[
i
];
if
(
!
w
)
continue
;
if
(
w
.
options
&&
w
.
options
.
property
&&
this
.
properties
[
w
.
options
.
property
])
if
(
w
.
options
&&
w
.
options
.
property
&&
(
this
.
properties
[
w
.
options
.
property
]
!=
undefined
)
)
w
.
value
=
JSON
.
parse
(
JSON
.
stringify
(
this
.
properties
[
w
.
options
.
property
]
)
);
}
if
(
info
.
widgets_values
)
{
...
...
@@ -5714,10 +5714,10 @@ LGraphNode.prototype.executeAction = function(action)
* @method enableWebGL
**/
LGraphCanvas
.
prototype
.
enableWebGL
=
function
()
{
if
(
typeof
GL
===
undefined
)
{
if
(
typeof
GL
===
"
undefined
"
)
{
throw
"
litegl.js must be included to use a WebGL canvas
"
;
}
if
(
typeof
enableWebGLCanvas
===
undefined
)
{
if
(
typeof
enableWebGLCanvas
===
"
undefined
"
)
{
throw
"
webglCanvas.js must be included to use this feature
"
;
}
...
...
@@ -7110,15 +7110,16 @@ LGraphNode.prototype.executeAction = function(action)
}
};
LGraphCanvas
.
prototype
.
copyToClipboard
=
function
()
{
LGraphCanvas
.
prototype
.
copyToClipboard
=
function
(
nodes
)
{
var
clipboard_info
=
{
nodes
:
[],
links
:
[]
};
var
index
=
0
;
var
selected_nodes_array
=
[];
for
(
var
i
in
this
.
selected_nodes
)
{
var
node
=
this
.
selected_nodes
[
i
];
if
(
!
nodes
)
nodes
=
this
.
selected_nodes
;
for
(
var
i
in
nodes
)
{
var
node
=
nodes
[
i
];
if
(
node
.
clonable
===
false
)
continue
;
node
.
_relative_id
=
index
;
...
...
@@ -11702,7 +11703,7 @@ LGraphNode.prototype.executeAction = function(action)
default
:
iS
=
0
;
// try with first if no name set
}
if
(
typeof
options
.
node_from
.
outputs
[
iS
]
!==
undefined
){
if
(
typeof
options
.
node_from
.
outputs
[
iS
]
!==
"
undefined
"
){
if
(
iS
!==
false
&&
iS
>-
1
){
options
.
node_from
.
connectByType
(
iS
,
node
,
options
.
node_from
.
outputs
[
iS
].
type
);
}
...
...
@@ -11730,7 +11731,7 @@ LGraphNode.prototype.executeAction = function(action)
default
:
iS
=
0
;
// try with first if no name set
}
if
(
typeof
options
.
node_to
.
inputs
[
iS
]
!==
undefined
){
if
(
typeof
options
.
node_to
.
inputs
[
iS
]
!==
"
undefined
"
){
if
(
iS
!==
false
&&
iS
>-
1
){
// try connection
options
.
node_to
.
connectByTypeOutput
(
iS
,
node
,
options
.
node_to
.
inputs
[
iS
].
type
);
...
...
web/scripts/api.js
View file @
c92f3dca
...
...
@@ -254,9 +254,9 @@ class ComfyApi extends EventTarget {
* Gets the prompt execution history
* @returns Prompt history including node outputs
*/
async
getHistory
()
{
async
getHistory
(
max_items
=
200
)
{
try
{
const
res
=
await
this
.
fetchApi
(
"
/history
"
);
const
res
=
await
this
.
fetchApi
(
`
/history
?max_items=
${
max_items
}
`
);
return
{
History
:
Object
.
values
(
await
res
.
json
())
};
}
catch
(
error
)
{
console
.
error
(
error
);
...
...
web/scripts/app.js
View file @
c92f3dca
...
...
@@ -4,7 +4,10 @@ import { ComfyUI, $el } from "./ui.js";
import
{
api
}
from
"
./api.js
"
;
import
{
defaultGraph
}
from
"
./defaultGraph.js
"
;
import
{
getPngMetadata
,
getWebpMetadata
,
importA1111
,
getLatentMetadata
}
from
"
./pnginfo.js
"
;
import
{
addDomClippingSetting
}
from
"
./domWidget.js
"
;
import
{
createImageHost
,
calculateImageGrid
}
from
"
./ui/imagePreview.js
"
export
const
ANIM_PREVIEW_WIDGET
=
"
$$comfy_animation_preview
"
function
sanitizeNodeName
(
string
)
{
let
entityMap
=
{
...
...
@@ -409,7 +412,9 @@ export class ComfyApp {
return
shiftY
;
}
node
.
prototype
.
setSizeForImage
=
function
()
{
node
.
prototype
.
setSizeForImage
=
function
(
force
)
{
if
(
!
force
&&
this
.
animatedImages
)
return
;
if
(
this
.
inputHeight
)
{
this
.
setSize
(
this
.
size
);
return
;
...
...
@@ -426,13 +431,20 @@ export class ComfyApp {
let
imagesChanged
=
false
const
output
=
app
.
nodeOutputs
[
this
.
id
+
""
];
if
(
output
&&
output
.
images
)
{
if
(
output
?.
images
)
{
this
.
animatedImages
=
output
?.
animated
?.
find
(
Boolean
);
if
(
this
.
images
!==
output
.
images
)
{
this
.
images
=
output
.
images
;
imagesChanged
=
true
;
imgURLs
=
imgURLs
.
concat
(
output
.
images
.
map
(
params
=>
{
return
api
.
apiURL
(
"
/view?
"
+
new
URLSearchParams
(
params
).
toString
()
+
app
.
getPreviewFormatParam
()
+
app
.
getRandParam
());
}))
imgURLs
=
imgURLs
.
concat
(
output
.
images
.
map
((
params
)
=>
{
return
api
.
apiURL
(
"
/view?
"
+
new
URLSearchParams
(
params
).
toString
()
+
(
this
.
animatedImages
?
""
:
app
.
getPreviewFormatParam
())
+
app
.
getRandParam
()
);
})
);
}
}
...
...
@@ -511,7 +523,35 @@ export class ComfyApp {
return
true
;
}
if
(
this
.
imgs
&&
this
.
imgs
.
length
)
{
if
(
this
.
imgs
?.
length
)
{
const
widgetIdx
=
this
.
widgets
?.
findIndex
((
w
)
=>
w
.
name
===
ANIM_PREVIEW_WIDGET
);
if
(
this
.
animatedImages
)
{
// Instead of using the canvas we'll use a IMG
if
(
widgetIdx
>
-
1
)
{
// Replace content
const
widget
=
this
.
widgets
[
widgetIdx
];
widget
.
options
.
host
.
updateImages
(
this
.
imgs
);
}
else
{
const
host
=
createImageHost
(
this
);
this
.
setSizeForImage
(
true
);
const
widget
=
this
.
addDOMWidget
(
ANIM_PREVIEW_WIDGET
,
"
img
"
,
host
.
el
,
{
host
,
getHeight
:
host
.
getHeight
,
onDraw
:
host
.
onDraw
,
hideOnZoom
:
false
});
widget
.
serializeValue
=
()
=>
undefined
;
widget
.
options
.
host
.
updateImages
(
this
.
imgs
);
}
return
;
}
if
(
widgetIdx
>
-
1
)
{
this
.
widgets
[
widgetIdx
].
onRemove
?.();
this
.
widgets
.
splice
(
widgetIdx
,
1
);
}
const
canvas
=
app
.
graph
.
list_of_graphcanvas
[
0
];
const
mouse
=
canvas
.
graph_mouse
;
if
(
!
canvas
.
pointer_is_down
&&
this
.
pointerDown
)
{
...
...
@@ -551,31 +591,7 @@ export class ComfyApp {
}
else
{
cell_padding
=
0
;
let
best
=
0
;
let
w
=
this
.
imgs
[
0
].
naturalWidth
;
let
h
=
this
.
imgs
[
0
].
naturalHeight
;
// compact style
for
(
let
c
=
1
;
c
<=
numImages
;
c
++
)
{
const
rows
=
Math
.
ceil
(
numImages
/
c
);
const
cW
=
dw
/
c
;
const
cH
=
dh
/
rows
;
const
scaleX
=
cW
/
w
;
const
scaleY
=
cH
/
h
;
const
scale
=
Math
.
min
(
scaleX
,
scaleY
,
1
);
const
imageW
=
w
*
scale
;
const
imageH
=
h
*
scale
;
const
area
=
imageW
*
imageH
*
numImages
;
if
(
area
>
best
)
{
best
=
area
;
cellWidth
=
imageW
;
cellHeight
=
imageH
;
cols
=
c
;
shiftX
=
c
*
((
cW
-
imageW
)
/
2
);
}
}
({
cellWidth
,
cellHeight
,
cols
,
shiftX
}
=
calculateImageGrid
(
this
.
imgs
,
dw
,
dh
));
}
let
anyHovered
=
false
;
...
...
@@ -767,7 +783,7 @@ export class ComfyApp {
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
#
addPasteHandler
()
{
document
.
addEventListener
(
"
paste
"
,
(
e
)
=>
{
document
.
addEventListener
(
"
paste
"
,
async
(
e
)
=>
{
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
if
(
this
.
shiftDown
)
return
;
...
...
@@ -815,7 +831,7 @@ export class ComfyApp {
}
if
(
workflow
&&
workflow
.
version
&&
workflow
.
nodes
&&
workflow
.
extra
)
{
this
.
loadGraphData
(
workflow
);
await
this
.
loadGraphData
(
workflow
);
}
else
{
if
(
e
.
target
.
type
===
"
text
"
||
e
.
target
.
type
===
"
textarea
"
)
{
...
...
@@ -1165,7 +1181,19 @@ export class ComfyApp {
});
api
.
addEventListener
(
"
executed
"
,
({
detail
})
=>
{
this
.
nodeOutputs
[
detail
.
node
]
=
detail
.
output
;
const
output
=
this
.
nodeOutputs
[
detail
.
node
];
if
(
detail
.
merge
&&
output
)
{
for
(
const
k
in
detail
.
output
??
{})
{
const
v
=
output
[
k
];
if
(
v
instanceof
Array
)
{
output
[
k
]
=
v
.
concat
(
detail
.
output
[
k
]);
}
else
{
output
[
k
]
=
detail
.
output
[
k
];
}
}
}
else
{
this
.
nodeOutputs
[
detail
.
node
]
=
detail
.
output
;
}
const
node
=
this
.
graph
.
getNodeById
(
detail
.
node
);
if
(
node
)
{
if
(
node
.
onExecuted
)
...
...
@@ -1276,9 +1304,11 @@ export class ComfyApp {
canvasEl
.
tabIndex
=
"
1
"
;
document
.
body
.
prepend
(
canvasEl
);
addDomClippingSetting
();
this
.
#
addProcessMouseHandler
();
this
.
#
addProcessKeyHandler
();
this
.
#
addConfigureHandler
();
this
.
#
addApiUpdateHandlers
();
this
.
graph
=
new
LGraph
();
...
...
@@ -1315,7 +1345,7 @@ export class ComfyApp {
const
json
=
localStorage
.
getItem
(
"
workflow
"
);
if
(
json
)
{
const
workflow
=
JSON
.
parse
(
json
);
this
.
loadGraphData
(
workflow
);
await
this
.
loadGraphData
(
workflow
);
restored
=
true
;
}
}
catch
(
err
)
{
...
...
@@ -1324,7 +1354,7 @@ export class ComfyApp {
// We failed to restore a workflow so load the default
if
(
!
restored
)
{
this
.
loadGraphData
();
await
this
.
loadGraphData
();
}
// Save current workflow automatically
...
...
@@ -1332,7 +1362,6 @@ export class ComfyApp {
this
.
#
addDrawNodeHandler
();
this
.
#
addDrawGroupsHandler
();
this
.
#
addApiUpdateHandlers
();
this
.
#
addDropHandler
();
this
.
#
addCopyHandler
();
this
.
#
addPasteHandler
();
...
...
@@ -1352,11 +1381,95 @@ export class ComfyApp {
await
this
.
#
invokeExtensionsAsync
(
"
registerCustomNodes
"
);
}
getWidgetType
(
inputData
,
inputName
)
{
const
type
=
inputData
[
0
];
if
(
Array
.
isArray
(
type
))
{
return
"
COMBO
"
;
}
else
if
(
`
${
type
}
:
${
inputName
}
`
in
this
.
widgets
)
{
return
`
${
type
}
:
${
inputName
}
`
;
}
else
if
(
type
in
this
.
widgets
)
{
return
type
;
}
else
{
return
null
;
}
}
async
registerNodeDef
(
nodeId
,
nodeData
)
{
const
self
=
this
;
const
node
=
Object
.
assign
(
function
ComfyNode
()
{
var
inputs
=
nodeData
[
"
input
"
][
"
required
"
];
if
(
nodeData
[
"
input
"
][
"
optional
"
]
!=
undefined
)
{
inputs
=
Object
.
assign
({},
nodeData
[
"
input
"
][
"
required
"
],
nodeData
[
"
input
"
][
"
optional
"
]);
}
const
config
=
{
minWidth
:
1
,
minHeight
:
1
};
for
(
const
inputName
in
inputs
)
{
const
inputData
=
inputs
[
inputName
];
const
type
=
inputData
[
0
];
let
widgetCreated
=
true
;
const
widgetType
=
self
.
getWidgetType
(
inputData
,
inputName
);
if
(
widgetType
)
{
if
(
widgetType
===
"
COMBO
"
)
{
Object
.
assign
(
config
,
self
.
widgets
.
COMBO
(
this
,
inputName
,
inputData
,
app
)
||
{});
}
else
{
Object
.
assign
(
config
,
self
.
widgets
[
widgetType
](
this
,
inputName
,
inputData
,
app
)
||
{});
}
}
else
{
// Node connection inputs
this
.
addInput
(
inputName
,
type
);
widgetCreated
=
false
;
}
if
(
widgetCreated
&&
inputData
[
1
]?.
forceInput
&&
config
?.
widget
)
{
if
(
!
config
.
widget
.
options
)
config
.
widget
.
options
=
{};
config
.
widget
.
options
.
forceInput
=
inputData
[
1
].
forceInput
;
}
if
(
widgetCreated
&&
inputData
[
1
]?.
defaultInput
&&
config
?.
widget
)
{
if
(
!
config
.
widget
.
options
)
config
.
widget
.
options
=
{};
config
.
widget
.
options
.
defaultInput
=
inputData
[
1
].
defaultInput
;
}
}
for
(
const
o
in
nodeData
[
"
output
"
])
{
let
output
=
nodeData
[
"
output
"
][
o
];
if
(
output
instanceof
Array
)
output
=
"
COMBO
"
;
const
outputName
=
nodeData
[
"
output_name
"
][
o
]
||
output
;
const
outputShape
=
nodeData
[
"
output_is_list
"
][
o
]
?
LiteGraph
.
GRID_SHAPE
:
LiteGraph
.
CIRCLE_SHAPE
;
this
.
addOutput
(
outputName
,
output
,
{
shape
:
outputShape
});
}
const
s
=
this
.
computeSize
();
s
[
0
]
=
Math
.
max
(
config
.
minWidth
,
s
[
0
]
*
1.5
);
s
[
1
]
=
Math
.
max
(
config
.
minHeight
,
s
[
1
]);
this
.
size
=
s
;
this
.
serialize_widgets
=
true
;
app
.
#
invokeExtensionsAsync
(
"
nodeCreated
"
,
this
);
},
{
title
:
nodeData
.
display_name
||
nodeData
.
name
,
comfyClass
:
nodeData
.
name
,
nodeData
}
);
node
.
prototype
.
comfyClass
=
nodeData
.
name
;
this
.
#
addNodeContextMenuHandler
(
node
);
this
.
#
addDrawBackgroundHandler
(
node
,
app
);
this
.
#
addNodeKeyHandler
(
node
);
await
this
.
#
invokeExtensionsAsync
(
"
beforeRegisterNodeDef
"
,
node
,
nodeData
);
LiteGraph
.
registerNodeType
(
nodeId
,
node
);
node
.
category
=
nodeData
.
category
;
}
async
registerNodesFromDefs
(
defs
)
{
await
this
.
#
invokeExtensionsAsync
(
"
addCustomNodeDefs
"
,
defs
);
// Generate list of known widgets
const
widgets
=
Object
.
assign
(
this
.
widgets
=
Object
.
assign
(
{},
ComfyWidgets
,
...(
await
this
.
#
invokeExtensionsAsync
(
"
getCustomWidgets
"
)).
filter
(
Boolean
)
...
...
@@ -1364,75 +1477,7 @@ export class ComfyApp {
// Register a node for each definition
for
(
const
nodeId
in
defs
)
{
const
nodeData
=
defs
[
nodeId
];
const
node
=
Object
.
assign
(
function
ComfyNode
()
{
var
inputs
=
nodeData
[
"
input
"
][
"
required
"
];
if
(
nodeData
[
"
input
"
][
"
optional
"
]
!=
undefined
){
inputs
=
Object
.
assign
({},
nodeData
[
"
input
"
][
"
required
"
],
nodeData
[
"
input
"
][
"
optional
"
])
}
const
config
=
{
minWidth
:
1
,
minHeight
:
1
};
for
(
const
inputName
in
inputs
)
{
const
inputData
=
inputs
[
inputName
];
const
type
=
inputData
[
0
];
let
widgetCreated
=
true
;
if
(
Array
.
isArray
(
type
))
{
// Enums
Object
.
assign
(
config
,
widgets
.
COMBO
(
this
,
inputName
,
inputData
,
app
)
||
{});
}
else
if
(
`
${
type
}
:
${
inputName
}
`
in
widgets
)
{
// Support custom widgets by Type:Name
Object
.
assign
(
config
,
widgets
[
`
${
type
}
:
${
inputName
}
`
](
this
,
inputName
,
inputData
,
app
)
||
{});
}
else
if
(
type
in
widgets
)
{
// Standard type widgets
Object
.
assign
(
config
,
widgets
[
type
](
this
,
inputName
,
inputData
,
app
)
||
{});
}
else
{
// Node connection inputs
this
.
addInput
(
inputName
,
type
);
widgetCreated
=
false
;
}
if
(
widgetCreated
&&
inputData
[
1
]?.
forceInput
&&
config
?.
widget
)
{
if
(
!
config
.
widget
.
options
)
config
.
widget
.
options
=
{};
config
.
widget
.
options
.
forceInput
=
inputData
[
1
].
forceInput
;
}
if
(
widgetCreated
&&
inputData
[
1
]?.
defaultInput
&&
config
?.
widget
)
{
if
(
!
config
.
widget
.
options
)
config
.
widget
.
options
=
{};
config
.
widget
.
options
.
defaultInput
=
inputData
[
1
].
defaultInput
;
}
}
for
(
const
o
in
nodeData
[
"
output
"
])
{
let
output
=
nodeData
[
"
output
"
][
o
];
if
(
output
instanceof
Array
)
output
=
"
COMBO
"
;
const
outputName
=
nodeData
[
"
output_name
"
][
o
]
||
output
;
const
outputShape
=
nodeData
[
"
output_is_list
"
][
o
]
?
LiteGraph
.
GRID_SHAPE
:
LiteGraph
.
CIRCLE_SHAPE
;
this
.
addOutput
(
outputName
,
output
,
{
shape
:
outputShape
});
}
const
s
=
this
.
computeSize
();
s
[
0
]
=
Math
.
max
(
config
.
minWidth
,
s
[
0
]
*
1.5
);
s
[
1
]
=
Math
.
max
(
config
.
minHeight
,
s
[
1
]);
this
.
size
=
s
;
this
.
serialize_widgets
=
true
;
app
.
#
invokeExtensionsAsync
(
"
nodeCreated
"
,
this
);
},
{
title
:
nodeData
.
display_name
||
nodeData
.
name
,
comfyClass
:
nodeData
.
name
,
nodeData
}
);
node
.
prototype
.
comfyClass
=
nodeData
.
name
;
this
.
#
addNodeContextMenuHandler
(
node
);
this
.
#
addDrawBackgroundHandler
(
node
,
app
);
this
.
#
addNodeKeyHandler
(
node
);
await
this
.
#
invokeExtensionsAsync
(
"
beforeRegisterNodeDef
"
,
node
,
nodeData
);
LiteGraph
.
registerNodeType
(
nodeId
,
node
);
node
.
category
=
nodeData
.
category
;
this
.
registerNodeDef
(
nodeId
,
defs
[
nodeId
]);
}
}
...
...
@@ -1475,9 +1520,14 @@ export class ComfyApp {
showMissingNodesError
(
missingNodeTypes
,
hasAddedNodes
=
true
)
{
this
.
ui
.
dialog
.
show
(
`When loading the graph, the following node types were not found: <ul>
${
Array
.
from
(
new
Set
(
missingNodeTypes
)).
map
(
(
t
)
=>
`<li>
${
t
}
</li>`
).
join
(
""
)}
</ul>
${
hasAddedNodes
?
"
Nodes that have failed to load will show as red on the graph.
"
:
""
}
`
$el
(
"
div
"
,
[
$el
(
"
span
"
,
{
textContent
:
"
When loading the graph, the following node types were not found:
"
}),
$el
(
"
ul
"
,
Array
.
from
(
new
Set
(
missingNodeTypes
)).
map
((
t
)
=>
$el
(
"
li
"
,
{
textContent
:
t
}))
),
...(
hasAddedNodes
?
[
$el
(
"
span
"
,
{
textContent
:
"
Nodes that have failed to load will show as red on the graph.
"
})]
:
[]),
])
);
this
.
logging
.
addEntry
(
"
Comfy.App
"
,
"
warn
"
,
{
MissingNodes
:
missingNodeTypes
,
...
...
@@ -1488,31 +1538,35 @@ export class ComfyApp {
* Populates the graph with the specified workflow data
* @param {*} graphData A serialized graph object
*/
loadGraphData
(
graphData
)
{
async
loadGraphData
(
graphData
)
{
this
.
clean
();
let
reset_invalid_values
=
false
;
if
(
!
graphData
)
{
if
(
typeof
structuredClone
===
"
undefined
"
)
{
graphData
=
JSON
.
parse
(
JSON
.
stringify
(
defaultGraph
));
}
else
{
graphData
=
structuredClone
(
defaultGraph
);
}
graphData
=
defaultGraph
;
reset_invalid_values
=
true
;
}
if
(
typeof
structuredClone
===
"
undefined
"
)
{
graphData
=
JSON
.
parse
(
JSON
.
stringify
(
graphData
));
}
else
{
graphData
=
structuredClone
(
graphData
);
}
const
missingNodeTypes
=
[];
await
this
.
#
invokeExtensionsAsync
(
"
beforeConfigureGraph
"
,
graphData
,
missingNodeTypes
);
for
(
let
n
of
graphData
.
nodes
)
{
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
if
(
n
.
type
==
"
T2IAdapterLoader
"
)
n
.
type
=
"
ControlNetLoader
"
;
if
(
n
.
type
==
"
ConditioningAverage
"
)
n
.
type
=
"
ConditioningAverage
"
;
//typo fix
if
(
n
.
type
==
"
SDV_img2vid_Conditioning
"
)
n
.
type
=
"
SVD_img2vid_Conditioning
"
;
//typo fix
// Find missing node types
if
(
!
(
n
.
type
in
LiteGraph
.
registered_node_types
))
{
n
.
type
=
sanitizeNodeName
(
n
.
type
);
missingNodeTypes
.
push
(
n
.
type
);
n
.
type
=
sanitizeNodeName
(
n
.
type
);
}
}
...
...
@@ -1604,6 +1658,7 @@ export class ComfyApp {
if
(
missingNodeTypes
.
length
)
{
this
.
showMissingNodesError
(
missingNodeTypes
);
}
await
this
.
#
invokeExtensionsAsync
(
"
afterConfigureGraph
"
,
missingNodeTypes
);
}
/**
...
...
@@ -1611,92 +1666,98 @@ export class ComfyApp {
* @returns The workflow and node links
*/
async
graphToPrompt
()
{
for
(
const
node
of
this
.
graph
.
computeExecutionOrder
(
false
))
{
if
(
node
.
isVirtualNode
)
{
// Don't serialize frontend only nodes but let them make changes
if
(
node
.
applyToGraph
)
{
node
.
applyToGraph
();
for
(
const
outerNode
of
this
.
graph
.
computeExecutionOrder
(
false
))
{
const
innerNodes
=
outerNode
.
getInnerNodes
?
outerNode
.
getInnerNodes
()
:
[
outerNode
];
for
(
const
node
of
innerNodes
)
{
if
(
node
.
isVirtualNode
)
{
// Don't serialize frontend only nodes but let them make changes
if
(
node
.
applyToGraph
)
{
node
.
applyToGraph
();
}
}
continue
;
}
}
const
workflow
=
this
.
graph
.
serialize
();
const
output
=
{};
// Process nodes in order of execution
for
(
const
n
ode
of
this
.
graph
.
computeExecutionOrder
(
false
))
{
const
n
=
workflow
.
nodes
.
find
((
n
)
=>
n
.
id
===
node
.
id
)
;
if
(
node
.
isVirtualNode
)
{
continue
;
}
for
(
const
outerN
ode
of
this
.
graph
.
computeExecutionOrder
(
false
))
{
const
innerNodes
=
outerNode
.
getInnerNodes
?
outerNode
.
getInnerNodes
()
:
[
outerNode
]
;
for
(
const
node
of
innerNodes
)
{
if
(
node
.
isVirtualNode
)
{
continue
;
}
if
(
node
.
mode
===
2
||
node
.
mode
===
4
)
{
// Don't serialize muted nodes
continue
;
}
if
(
node
.
mode
===
2
||
node
.
mode
===
4
)
{
// Don't serialize muted nodes
continue
;
}
const
inputs
=
{};
const
widgets
=
node
.
widgets
;
const
inputs
=
{};
const
widgets
=
node
.
widgets
;
// Store all widget values
if
(
widgets
)
{
for
(
const
i
in
widgets
)
{
const
widget
=
widgets
[
i
];
if
(
!
widget
.
options
||
widget
.
options
.
serialize
!==
false
)
{
inputs
[
widget
.
name
]
=
widget
.
serializeValue
?
await
widget
.
serializeValue
(
n
,
i
)
:
widget
.
value
;
// Store all widget values
if
(
widgets
)
{
for
(
const
i
in
widgets
)
{
const
widget
=
widgets
[
i
];
if
(
!
widget
.
options
||
widget
.
options
.
serialize
!==
false
)
{
inputs
[
widget
.
name
]
=
widget
.
serializeValue
?
await
widget
.
serializeValue
(
node
,
i
)
:
widget
.
value
;
}
}
}
}
// Store all node links
for
(
let
i
in
node
.
inputs
)
{
let
parent
=
node
.
getInputNode
(
i
);
if
(
parent
)
{
let
link
=
node
.
getInputLink
(
i
);
while
(
parent
.
mode
===
4
||
parent
.
isVirtualNode
)
{
let
found
=
false
;
if
(
parent
.
isVirtualNode
)
{
link
=
parent
.
getInputLink
(
link
.
origin_slot
);
if
(
link
)
{
parent
=
parent
.
getInputNode
(
link
.
target_slot
);
if
(
parent
)
{
found
=
true
;
// Store all node links
for
(
let
i
in
node
.
inputs
)
{
let
parent
=
node
.
getInputNode
(
i
);
if
(
parent
)
{
let
link
=
node
.
getInputLink
(
i
);
while
(
parent
.
mode
===
4
||
parent
.
isVirtualNode
)
{
let
found
=
false
;
if
(
parent
.
isVirtualNode
)
{
link
=
parent
.
getInputLink
(
link
.
origin_slot
);
if
(
link
)
{
parent
=
parent
.
getInputNode
(
link
.
target_slot
);
if
(
parent
)
{
found
=
true
;
}
}
}
}
else
if
(
link
&&
parent
.
mode
===
4
)
{
let
all_inputs
=
[
link
.
origin_slot
];
if
(
parent
.
inputs
)
{
all_inputs
=
all_inputs
.
concat
(
Object
.
keys
(
parent
.
inputs
))
for
(
let
parent_input
in
all_inputs
)
{
parent_input
=
all_inputs
[
parent_input
];
if
(
parent
.
inputs
[
parent_input
]?.
type
===
node
.
inputs
[
i
].
type
)
{
link
=
parent
.
getInputLink
(
parent_input
);
if
(
link
)
{
parent
=
parent
.
getInputNode
(
parent_input
);
}
else
if
(
link
&&
parent
.
mode
===
4
)
{
let
all_inputs
=
[
link
.
origin_slot
];
if
(
parent
.
inputs
)
{
all_inputs
=
all_inputs
.
concat
(
Object
.
keys
(
parent
.
inputs
))
for
(
let
parent_input
in
all_inputs
)
{
parent_input
=
all_inputs
[
parent_input
];
if
(
parent
.
inputs
[
parent_input
]?.
type
===
node
.
inputs
[
i
].
type
)
{
link
=
parent
.
getInputLink
(
parent_input
);
if
(
link
)
{
parent
=
parent
.
getInputNode
(
parent_input
);
}
found
=
true
;
break
;
}
found
=
true
;
break
;
}
}
}
}
if
(
!
found
)
{
break
;
if
(
!
found
)
{
break
;
}
}
}
if
(
link
)
{
inputs
[
node
.
inputs
[
i
].
name
]
=
[
String
(
link
.
origin_id
),
parseInt
(
link
.
origin_slot
)];
if
(
link
)
{
if
(
parent
?.
updateLink
)
{
link
=
parent
.
updateLink
(
link
);
}
inputs
[
node
.
inputs
[
i
].
name
]
=
[
String
(
link
.
origin_id
),
parseInt
(
link
.
origin_slot
)];
}
}
}
}
output
[
String
(
node
.
id
)]
=
{
inputs
,
class_type
:
node
.
comfyClass
,
};
output
[
String
(
node
.
id
)]
=
{
inputs
,
class_type
:
node
.
comfyClass
,
};
}
}
// Remove inputs connected to removed nodes
...
...
@@ -1816,7 +1877,7 @@ export class ComfyApp {
const
pngInfo
=
await
getPngMetadata
(
file
);
if
(
pngInfo
)
{
if
(
pngInfo
.
workflow
)
{
this
.
loadGraphData
(
JSON
.
parse
(
pngInfo
.
workflow
));
await
this
.
loadGraphData
(
JSON
.
parse
(
pngInfo
.
workflow
));
}
else
if
(
pngInfo
.
parameters
)
{
importA1111
(
this
.
graph
,
pngInfo
.
parameters
);
}
...
...
@@ -1832,21 +1893,21 @@ export class ComfyApp {
}
}
else
if
(
file
.
type
===
"
application/json
"
||
file
.
name
?.
endsWith
(
"
.json
"
))
{
const
reader
=
new
FileReader
();
reader
.
onload
=
()
=>
{
reader
.
onload
=
async
()
=>
{
const
jsonContent
=
JSON
.
parse
(
reader
.
result
);
if
(
jsonContent
?.
templates
)
{
this
.
loadTemplateData
(
jsonContent
);
}
else
if
(
this
.
isApiJson
(
jsonContent
))
{
this
.
loadApiJson
(
jsonContent
);
}
else
{
this
.
loadGraphData
(
jsonContent
);
await
this
.
loadGraphData
(
jsonContent
);
}
};
reader
.
readAsText
(
file
);
}
else
if
(
file
.
name
?.
endsWith
(
"
.latent
"
)
||
file
.
name
?.
endsWith
(
"
.safetensors
"
))
{
const
info
=
await
getLatentMetadata
(
file
);
if
(
info
.
workflow
)
{
this
.
loadGraphData
(
JSON
.
parse
(
info
.
workflow
));
await
this
.
loadGraphData
(
JSON
.
parse
(
info
.
workflow
));
}
}
}
...
...
@@ -1867,7 +1928,7 @@ export class ComfyApp {
for
(
const
id
of
ids
)
{
const
data
=
apiData
[
id
];
const
node
=
LiteGraph
.
createNode
(
data
.
class_type
);
node
.
id
=
id
;
node
.
id
=
isNaN
(
+
id
)
?
id
:
+
id
;
graph
.
add
(
node
);
}
...
...
web/scripts/domWidget.js
0 → 100644
View file @
c92f3dca
import
{
app
,
ANIM_PREVIEW_WIDGET
}
from
"
./app.js
"
;
const
SIZE
=
Symbol
();
function
intersect
(
a
,
b
)
{
const
x
=
Math
.
max
(
a
.
x
,
b
.
x
);
const
num1
=
Math
.
min
(
a
.
x
+
a
.
width
,
b
.
x
+
b
.
width
);
const
y
=
Math
.
max
(
a
.
y
,
b
.
y
);
const
num2
=
Math
.
min
(
a
.
y
+
a
.
height
,
b
.
y
+
b
.
height
);
if
(
num1
>=
x
&&
num2
>=
y
)
return
[
x
,
y
,
num1
-
x
,
num2
-
y
];
else
return
null
;
}
function
getClipPath
(
node
,
element
,
elRect
)
{
const
selectedNode
=
Object
.
values
(
app
.
canvas
.
selected_nodes
)[
0
];
if
(
selectedNode
&&
selectedNode
!==
node
)
{
const
MARGIN
=
7
;
const
scale
=
app
.
canvas
.
ds
.
scale
;
const
bounding
=
selectedNode
.
getBounding
();
const
intersection
=
intersect
(
{
x
:
elRect
.
x
/
scale
,
y
:
elRect
.
y
/
scale
,
width
:
elRect
.
width
/
scale
,
height
:
elRect
.
height
/
scale
},
{
x
:
selectedNode
.
pos
[
0
]
+
app
.
canvas
.
ds
.
offset
[
0
]
-
MARGIN
,
y
:
selectedNode
.
pos
[
1
]
+
app
.
canvas
.
ds
.
offset
[
1
]
-
LiteGraph
.
NODE_TITLE_HEIGHT
-
MARGIN
,
width
:
bounding
[
2
]
+
MARGIN
+
MARGIN
,
height
:
bounding
[
3
]
+
MARGIN
+
MARGIN
,
}
);
if
(
!
intersection
)
{
return
""
;
}
const
widgetRect
=
element
.
getBoundingClientRect
();
const
clipX
=
intersection
[
0
]
-
widgetRect
.
x
/
scale
+
"
px
"
;
const
clipY
=
intersection
[
1
]
-
widgetRect
.
y
/
scale
+
"
px
"
;
const
clipWidth
=
intersection
[
2
]
+
"
px
"
;
const
clipHeight
=
intersection
[
3
]
+
"
px
"
;
const
path
=
`polygon(0% 0%, 0% 100%,
${
clipX
}
100%,
${
clipX
}
${
clipY
}
, calc(
${
clipX
}
+
${
clipWidth
}
)
${
clipY
}
, calc(
${
clipX
}
+
${
clipWidth
}
) calc(
${
clipY
}
+
${
clipHeight
}
),
${
clipX
}
calc(
${
clipY
}
+
${
clipHeight
}
),
${
clipX
}
100%, 100% 100%, 100% 0%)`
;
return
path
;
}
return
""
;
}
function
computeSize
(
size
)
{
if
(
this
.
widgets
?.[
0
]?.
last_y
==
null
)
return
;
let
y
=
this
.
widgets
[
0
].
last_y
;
let
freeSpace
=
size
[
1
]
-
y
;
let
widgetHeight
=
0
;
let
dom
=
[];
for
(
const
w
of
this
.
widgets
)
{
if
(
w
.
type
===
"
converted-widget
"
)
{
// Ignore
delete
w
.
computedHeight
;
}
else
if
(
w
.
computeSize
)
{
widgetHeight
+=
w
.
computeSize
()[
1
]
+
4
;
}
else
if
(
w
.
element
)
{
// Extract DOM widget size info
const
styles
=
getComputedStyle
(
w
.
element
);
let
minHeight
=
w
.
options
.
getMinHeight
?.()
??
parseInt
(
styles
.
getPropertyValue
(
"
--comfy-widget-min-height
"
));
let
maxHeight
=
w
.
options
.
getMaxHeight
?.()
??
parseInt
(
styles
.
getPropertyValue
(
"
--comfy-widget-max-height
"
));
let
prefHeight
=
w
.
options
.
getHeight
?.()
??
styles
.
getPropertyValue
(
"
--comfy-widget-height
"
);
if
(
prefHeight
.
endsWith
?.(
"
%
"
))
{
prefHeight
=
size
[
1
]
*
(
parseFloat
(
prefHeight
.
substring
(
0
,
prefHeight
.
length
-
1
))
/
100
);
}
else
{
prefHeight
=
parseInt
(
prefHeight
);
if
(
isNaN
(
minHeight
))
{
minHeight
=
prefHeight
;
}
}
if
(
isNaN
(
minHeight
))
{
minHeight
=
50
;
}
if
(
!
isNaN
(
maxHeight
))
{
if
(
!
isNaN
(
prefHeight
))
{
prefHeight
=
Math
.
min
(
prefHeight
,
maxHeight
);
}
else
{
prefHeight
=
maxHeight
;
}
}
dom
.
push
({
minHeight
,
prefHeight
,
w
,
});
}
else
{
widgetHeight
+=
LiteGraph
.
NODE_WIDGET_HEIGHT
+
4
;
}
}
freeSpace
-=
widgetHeight
;
// Calculate sizes with all widgets at their min height
const
prefGrow
=
[];
// Nodes that want to grow to their prefd size
const
canGrow
=
[];
// Nodes that can grow to auto size
let
growBy
=
0
;
for
(
const
d
of
dom
)
{
freeSpace
-=
d
.
minHeight
;
if
(
isNaN
(
d
.
prefHeight
))
{
canGrow
.
push
(
d
);
d
.
w
.
computedHeight
=
d
.
minHeight
;
}
else
{
const
diff
=
d
.
prefHeight
-
d
.
minHeight
;
if
(
diff
>
0
)
{
prefGrow
.
push
(
d
);
growBy
+=
diff
;
d
.
diff
=
diff
;
}
else
{
d
.
w
.
computedHeight
=
d
.
minHeight
;
}
}
}
if
(
this
.
imgs
&&
!
this
.
widgets
.
find
((
w
)
=>
w
.
name
===
ANIM_PREVIEW_WIDGET
))
{
// Allocate space for image
freeSpace
-=
220
;
}
if
(
freeSpace
<
0
)
{
// Not enough space for all widgets so we need to grow
size
[
1
]
-=
freeSpace
;
this
.
graph
.
setDirtyCanvas
(
true
);
}
else
{
// Share the space between each
const
growDiff
=
freeSpace
-
growBy
;
if
(
growDiff
>
0
)
{
// All pref sizes can be fulfilled
freeSpace
=
growDiff
;
for
(
const
d
of
prefGrow
)
{
d
.
w
.
computedHeight
=
d
.
prefHeight
;
}
}
else
{
// We need to grow evenly
const
shared
=
-
growDiff
/
prefGrow
.
length
;
for
(
const
d
of
prefGrow
)
{
d
.
w
.
computedHeight
=
d
.
prefHeight
-
shared
;
}
freeSpace
=
0
;
}
if
(
freeSpace
>
0
&&
canGrow
.
length
)
{
// Grow any that are auto height
const
shared
=
freeSpace
/
canGrow
.
length
;
for
(
const
d
of
canGrow
)
{
d
.
w
.
computedHeight
+=
shared
;
}
}
}
// Position each of the widgets
for
(
const
w
of
this
.
widgets
)
{
w
.
y
=
y
;
if
(
w
.
computedHeight
)
{
y
+=
w
.
computedHeight
;
}
else
if
(
w
.
computeSize
)
{
y
+=
w
.
computeSize
()[
1
]
+
4
;
}
else
{
y
+=
LiteGraph
.
NODE_WIDGET_HEIGHT
+
4
;
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const
elementWidgets
=
new
Set
();
const
computeVisibleNodes
=
LGraphCanvas
.
prototype
.
computeVisibleNodes
;
LGraphCanvas
.
prototype
.
computeVisibleNodes
=
function
()
{
const
visibleNodes
=
computeVisibleNodes
.
apply
(
this
,
arguments
);
for
(
const
node
of
app
.
graph
.
_nodes
)
{
if
(
elementWidgets
.
has
(
node
))
{
const
hidden
=
visibleNodes
.
indexOf
(
node
)
===
-
1
;
for
(
const
w
of
node
.
widgets
)
{
if
(
w
.
element
)
{
w
.
element
.
hidden
=
hidden
;
if
(
hidden
)
{
w
.
options
.
onHide
?.(
w
);
}
}
}
}
}
return
visibleNodes
;
};
let
enableDomClipping
=
true
;
export
function
addDomClippingSetting
()
{
app
.
ui
.
settings
.
addSetting
({
id
:
"
Comfy.DOMClippingEnabled
"
,
name
:
"
Enable DOM element clipping (enabling may reduce performance)
"
,
type
:
"
boolean
"
,
defaultValue
:
enableDomClipping
,
onChange
(
value
)
{
enableDomClipping
=
!!
value
;
},
});
}
LGraphNode
.
prototype
.
addDOMWidget
=
function
(
name
,
type
,
element
,
options
)
{
options
=
{
hideOnZoom
:
true
,
selectOn
:
[
"
focus
"
,
"
click
"
],
...
options
};
if
(
!
element
.
parentElement
)
{
document
.
body
.
append
(
element
);
}
let
mouseDownHandler
;
if
(
element
.
blur
)
{
mouseDownHandler
=
(
event
)
=>
{
if
(
!
element
.
contains
(
event
.
target
))
{
element
.
blur
();
}
};
document
.
addEventListener
(
"
mousedown
"
,
mouseDownHandler
);
}
const
widget
=
{
type
,
name
,
get
value
()
{
return
options
.
getValue
?.()
??
undefined
;
},
set
value
(
v
)
{
options
.
setValue
?.(
v
);
widget
.
callback
?.(
widget
.
value
);
},
draw
:
function
(
ctx
,
node
,
widgetWidth
,
y
,
widgetHeight
)
{
if
(
widget
.
computedHeight
==
null
)
{
computeSize
.
call
(
node
,
node
.
size
);
}
const
hidden
=
node
.
flags
?.
collapsed
||
(
!!
options
.
hideOnZoom
&&
app
.
canvas
.
ds
.
scale
<
0.5
)
||
widget
.
computedHeight
<=
0
||
widget
.
type
===
"
converted-widget
"
;
element
.
hidden
=
hidden
;
element
.
style
.
display
=
hidden
?
"
none
"
:
null
;
if
(
hidden
)
{
widget
.
options
.
onHide
?.(
widget
);
return
;
}
const
margin
=
10
;
const
elRect
=
ctx
.
canvas
.
getBoundingClientRect
();
const
transform
=
new
DOMMatrix
()
.
scaleSelf
(
elRect
.
width
/
ctx
.
canvas
.
width
,
elRect
.
height
/
ctx
.
canvas
.
height
)
.
multiplySelf
(
ctx
.
getTransform
())
.
translateSelf
(
margin
,
margin
+
y
);
const
scale
=
new
DOMMatrix
().
scaleSelf
(
transform
.
a
,
transform
.
d
);
Object
.
assign
(
element
.
style
,
{
transformOrigin
:
"
0 0
"
,
transform
:
scale
,
left
:
`
${
transform
.
a
+
transform
.
e
}
px`
,
top
:
`
${
transform
.
d
+
transform
.
f
}
px`
,
width
:
`
${
widgetWidth
-
margin
*
2
}
px`
,
height
:
`
${(
widget
.
computedHeight
??
50
)
-
margin
*
2
}
px
`,
position: "absolute",
zIndex: app.graph._nodes.indexOf(node),
});
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element, elRect);
element.style.willChange = "clip-path";
}
this.options.onDraw?.(widget);
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
}
element.remove();
},
};
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
}
this.addCustomWidget(widget);
elementWidgets.add(this);
const collapse = this.collapse;
this.collapse = function() {
collapse.apply(this, arguments);
if(this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
}
}
const onRemoved = this.onRemoved;
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
}
return widget;
};
web/scripts/pnginfo.js
View file @
c92f3dca
...
...
@@ -24,7 +24,7 @@ export function getPngMetadata(file) {
const
length
=
dataView
.
getUint32
(
offset
);
// Get the chunk type
const
type
=
String
.
fromCharCode
(...
pngData
.
slice
(
offset
+
4
,
offset
+
8
));
if
(
type
===
"
tEXt
"
)
{
if
(
type
===
"
tEXt
"
||
type
==
"
comf
"
)
{
// Get the keyword
let
keyword_end
=
offset
+
8
;
while
(
pngData
[
keyword_end
]
!==
0
)
{
...
...
@@ -50,7 +50,6 @@ export function getPngMetadata(file) {
function
parseExifData
(
exifData
)
{
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const
isLittleEndian
=
new
Uint16Array
(
exifData
.
slice
(
0
,
2
))[
0
]
===
0x4949
;
console
.
log
(
exifData
);
// Function to read 16-bit and 32-bit integers from binary data
function
readInt
(
offset
,
isLittleEndian
,
length
)
{
...
...
@@ -126,6 +125,9 @@ export function getWebpMetadata(file) {
const
chunk_length
=
dataView
.
getUint32
(
offset
+
4
,
true
);
const
chunk_type
=
String
.
fromCharCode
(...
webp
.
slice
(
offset
,
offset
+
4
));
if
(
chunk_type
===
"
EXIF
"
)
{
if
(
String
.
fromCharCode
(...
webp
.
slice
(
offset
+
8
,
offset
+
8
+
6
))
==
"
Exif
\
0
\
0
"
)
{
offset
+=
6
;
}
let
data
=
parseExifData
(
webp
.
slice
(
offset
+
8
,
offset
+
8
+
chunk_length
));
for
(
var
key
in
data
)
{
var
value
=
data
[
key
];
...
...
web/scripts/ui.js
View file @
c92f3dca
...
...
@@ -462,8 +462,8 @@ class ComfyList {
return
$el
(
"
div
"
,
{
textContent
:
item
.
prompt
[
0
]
+
"
:
"
},
[
$el
(
"
button
"
,
{
textContent
:
"
Load
"
,
onclick
:
()
=>
{
app
.
loadGraphData
(
item
.
prompt
[
3
].
extra_pnginfo
.
workflow
);
onclick
:
async
()
=>
{
await
app
.
loadGraphData
(
item
.
prompt
[
3
].
extra_pnginfo
.
workflow
);
if
(
item
.
outputs
)
{
app
.
nodeOutputs
=
item
.
outputs
;
}
...
...
@@ -599,7 +599,7 @@ export class ComfyUI {
const
fileInput
=
$el
(
"
input
"
,
{
id
:
"
comfy-file-input
"
,
type
:
"
file
"
,
accept
:
"
.json,image/png,.latent,.safetensors
"
,
accept
:
"
.json,image/png,.latent,.safetensors
,image/webp
"
,
style
:
{
display
:
"
none
"
},
parent
:
document
.
body
,
onchange
:
()
=>
{
...
...
@@ -784,9 +784,9 @@ export class ComfyUI {
}
}),
$el
(
"
button
"
,
{
id
:
"
comfy-load-default-button
"
,
textContent
:
"
Load Default
"
,
onclick
:
()
=>
{
id
:
"
comfy-load-default-button
"
,
textContent
:
"
Load Default
"
,
onclick
:
async
()
=>
{
if
(
!
confirmClear
.
value
||
confirm
(
"
Load default workflow?
"
))
{
app
.
loadGraphData
()
await
app
.
loadGraphData
()
}
}
}),
...
...
web/scripts/ui/imagePreview.js
0 → 100644
View file @
c92f3dca
import
{
$el
}
from
"
../ui.js
"
;
export
function
calculateImageGrid
(
imgs
,
dw
,
dh
)
{
let
best
=
0
;
let
w
=
imgs
[
0
].
naturalWidth
;
let
h
=
imgs
[
0
].
naturalHeight
;
const
numImages
=
imgs
.
length
;
let
cellWidth
,
cellHeight
,
cols
,
rows
,
shiftX
;
// compact style
for
(
let
c
=
1
;
c
<=
numImages
;
c
++
)
{
const
r
=
Math
.
ceil
(
numImages
/
c
);
const
cW
=
dw
/
c
;
const
cH
=
dh
/
r
;
const
scaleX
=
cW
/
w
;
const
scaleY
=
cH
/
h
;
const
scale
=
Math
.
min
(
scaleX
,
scaleY
,
1
);
const
imageW
=
w
*
scale
;
const
imageH
=
h
*
scale
;
const
area
=
imageW
*
imageH
*
numImages
;
if
(
area
>
best
)
{
best
=
area
;
cellWidth
=
imageW
;
cellHeight
=
imageH
;
cols
=
c
;
rows
=
r
;
shiftX
=
c
*
((
cW
-
imageW
)
/
2
);
}
}
return
{
cellWidth
,
cellHeight
,
cols
,
rows
,
shiftX
};
}
export
function
createImageHost
(
node
)
{
const
el
=
$el
(
"
div.comfy-img-preview
"
);
let
currentImgs
;
let
first
=
true
;
function
updateSize
()
{
let
w
=
null
;
let
h
=
null
;
if
(
currentImgs
)
{
let
elH
=
el
.
clientHeight
;
if
(
first
)
{
first
=
false
;
// On first run, if we are small then grow a bit
if
(
elH
<
190
)
{
elH
=
190
;
}
el
.
style
.
setProperty
(
"
--comfy-widget-min-height
"
,
elH
);
}
else
{
el
.
style
.
setProperty
(
"
--comfy-widget-min-height
"
,
null
);
}
const
nw
=
node
.
size
[
0
];
({
cellWidth
:
w
,
cellHeight
:
h
}
=
calculateImageGrid
(
currentImgs
,
nw
-
20
,
elH
));
w
+=
"
px
"
;
h
+=
"
px
"
;
el
.
style
.
setProperty
(
"
--comfy-img-preview-width
"
,
w
);
el
.
style
.
setProperty
(
"
--comfy-img-preview-height
"
,
h
);
}
}
return
{
el
,
updateImages
(
imgs
)
{
if
(
imgs
!==
currentImgs
)
{
if
(
currentImgs
==
null
)
{
requestAnimationFrame
(()
=>
{
updateSize
();
});
}
el
.
replaceChildren
(...
imgs
);
currentImgs
=
imgs
;
node
.
onResize
(
node
.
size
);
node
.
graph
.
setDirtyCanvas
(
true
,
true
);
}
},
getHeight
()
{
updateSize
();
},
onDraw
()
{
// Element from point uses a hittest find elements so we need to toggle pointer events
el
.
style
.
pointerEvents
=
"
all
"
;
const
over
=
document
.
elementFromPoint
(
app
.
canvas
.
mouse
[
0
],
app
.
canvas
.
mouse
[
1
]);
el
.
style
.
pointerEvents
=
"
none
"
;
if
(
!
over
)
return
;
// Set the overIndex so Open Image etc work
const
idx
=
currentImgs
.
indexOf
(
over
);
node
.
overIndex
=
idx
;
},
};
}
web/scripts/widgets.js
View file @
c92f3dca
import
{
api
}
from
"
./api.js
"
import
"
./domWidget.js
"
;
function
getNumberDefaults
(
inputData
,
defaultStep
,
precision
,
enable_rounding
)
{
let
defaultVal
=
inputData
[
1
][
"
default
"
];
...
...
@@ -22,18 +23,89 @@ function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) {
return
{
val
:
defaultVal
,
config
:
{
min
,
max
,
step
:
10.0
*
step
,
round
,
precision
}
};
}
export
function
addValueControlWidget
(
node
,
targetWidget
,
defaultValue
=
"
randomize
"
,
values
)
{
const
valueControl
=
node
.
addWidget
(
"
combo
"
,
"
control_after_generate
"
,
defaultValue
,
function
(
v
)
{
},
{
values
:
[
"
fixed
"
,
"
increment
"
,
"
decrement
"
,
"
randomize
"
],
serialize
:
false
,
// Don't include this in prompt.
});
valueControl
.
afterQueued
=
()
=>
{
export
function
addValueControlWidget
(
node
,
targetWidget
,
defaultValue
=
"
randomize
"
,
values
,
widgetName
,
inputData
)
{
let
name
=
inputData
[
1
]?.
control_after_generate
;
if
(
typeof
name
!==
"
string
"
)
{
name
=
widgetName
;
}
const
widgets
=
addValueControlWidgets
(
node
,
targetWidget
,
defaultValue
,
{
addFilterList
:
false
,
controlAfterGenerateName
:
name
},
inputData
);
return
widgets
[
0
];
}
export
function
addValueControlWidgets
(
node
,
targetWidget
,
defaultValue
=
"
randomize
"
,
options
,
inputData
)
{
if
(
!
defaultValue
)
defaultValue
=
"
randomize
"
;
if
(
!
options
)
options
=
{};
const
getName
=
(
defaultName
,
optionName
)
=>
{
let
name
=
defaultName
;
if
(
options
[
optionName
])
{
name
=
options
[
optionName
];
}
else
if
(
typeof
inputData
?.[
1
]?.[
defaultName
]
===
"
string
"
)
{
name
=
inputData
?.[
1
]?.[
defaultName
];
}
else
if
(
inputData
?.[
1
]?.
control_prefix
)
{
name
=
inputData
?.[
1
]?.
control_prefix
+
"
"
+
name
}
return
name
;
}
const
widgets
=
[];
const
valueControl
=
node
.
addWidget
(
"
combo
"
,
getName
(
"
control_after_generate
"
,
"
controlAfterGenerateName
"
),
defaultValue
,
function
()
{},
{
values
:
[
"
fixed
"
,
"
increment
"
,
"
decrement
"
,
"
randomize
"
],
serialize
:
false
,
// Don't include this in prompt.
}
);
widgets
.
push
(
valueControl
);
const
isCombo
=
targetWidget
.
type
===
"
combo
"
;
let
comboFilter
;
if
(
isCombo
&&
options
.
addFilterList
!==
false
)
{
comboFilter
=
node
.
addWidget
(
"
string
"
,
getName
(
"
control_filter_list
"
,
"
controlFilterListName
"
),
""
,
function
()
{},
{
serialize
:
false
,
// Don't include this in prompt.
}
);
widgets
.
push
(
comboFilter
);
}
valueControl
.
afterQueued
=
()
=>
{
var
v
=
valueControl
.
value
;
if
(
targetWidget
.
type
==
"
combo
"
&&
v
!==
"
fixed
"
)
{
let
current_index
=
targetWidget
.
options
.
values
.
indexOf
(
targetWidget
.
value
);
let
current_length
=
targetWidget
.
options
.
values
.
length
;
if
(
isCombo
&&
v
!==
"
fixed
"
)
{
let
values
=
targetWidget
.
options
.
values
;
const
filter
=
comboFilter
?.
value
;
if
(
filter
)
{
let
check
;
if
(
filter
.
startsWith
(
"
/
"
)
&&
filter
.
endsWith
(
"
/
"
))
{
try
{
const
regex
=
new
RegExp
(
filter
.
substring
(
1
,
filter
.
length
-
1
));
check
=
(
item
)
=>
regex
.
test
(
item
);
}
catch
(
error
)
{
console
.
error
(
"
Error constructing RegExp filter for node
"
+
node
.
id
,
filter
,
error
);
}
}
if
(
!
check
)
{
const
lower
=
filter
.
toLocaleLowerCase
();
check
=
(
item
)
=>
item
.
toLocaleLowerCase
().
includes
(
lower
);
}
values
=
values
.
filter
(
item
=>
check
(
item
));
if
(
!
values
.
length
&&
targetWidget
.
options
.
values
.
length
)
{
console
.
warn
(
"
Filter for node
"
+
node
.
id
+
"
has filtered out all items
"
,
filter
);
}
}
let
current_index
=
values
.
indexOf
(
targetWidget
.
value
);
let
current_length
=
values
.
length
;
switch
(
v
)
{
case
"
increment
"
:
...
...
@@ -50,11 +122,12 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random
current_index
=
Math
.
max
(
0
,
current_index
);
current_index
=
Math
.
min
(
current_length
-
1
,
current_index
);
if
(
current_index
>=
0
)
{
let
value
=
targetWidget
.
options
.
values
[
current_index
];
let
value
=
values
[
current_index
];
targetWidget
.
value
=
value
;
targetWidget
.
callback
(
value
);
}
}
else
{
//number
}
else
{
//number
let
min
=
targetWidget
.
options
.
min
;
let
max
=
targetWidget
.
options
.
max
;
// limit to something that javascript can handle
...
...
@@ -77,186 +150,68 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random
default
:
break
;
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if
(
targetWidget
.
value
<
min
)
targetWidget
.
value
=
min
;
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if
(
targetWidget
.
value
<
min
)
targetWidget
.
value
=
min
;
if
(
targetWidget
.
value
>
max
)
targetWidget
.
value
=
max
;
targetWidget
.
callback
(
targetWidget
.
value
);
}
}
return
valueControl
;
}
;
return
widgets
;
};
function
seedWidget
(
node
,
inputName
,
inputData
,
app
)
{
const
seed
=
Comfy
Widget
s
.
INT
(
node
,
inputName
,
inputData
,
app
);
const
seedControl
=
addValueControlWidget
(
node
,
seed
.
widget
,
"
randomize
"
);
function
seedWidget
(
node
,
inputName
,
inputData
,
app
,
widgetName
)
{
const
seed
=
createInt
Widget
(
node
,
inputName
,
inputData
,
app
,
true
);
const
seedControl
=
addValueControlWidget
(
node
,
seed
.
widget
,
"
randomize
"
,
undefined
,
widgetName
,
inputData
);
seed
.
widget
.
linkedWidgets
=
[
seedControl
];
return
seed
;
}
const
MultilineSymbol
=
Symbol
();
const
MultilineResizeSymbol
=
Symbol
();
function
addMultilineWidget
(
node
,
name
,
opts
,
app
)
{
const
MIN_SIZE
=
50
;
function
computeSize
(
size
)
{
if
(
node
.
widgets
[
0
].
last_y
==
null
)
return
;
let
y
=
node
.
widgets
[
0
].
last_y
;
let
freeSpace
=
size
[
1
]
-
y
;
// Compute the height of all non customtext widgets
let
widgetHeight
=
0
;
const
multi
=
[];
for
(
let
i
=
0
;
i
<
node
.
widgets
.
length
;
i
++
)
{
const
w
=
node
.
widgets
[
i
];
if
(
w
.
type
===
"
customtext
"
)
{
multi
.
push
(
w
);
}
else
{
if
(
w
.
computeSize
)
{
widgetHeight
+=
w
.
computeSize
()[
1
]
+
4
;
}
else
{
widgetHeight
+=
LiteGraph
.
NODE_WIDGET_HEIGHT
+
4
;
}
}
}
// See how large each text input can be
freeSpace
-=
widgetHeight
;
freeSpace
/=
multi
.
length
+
(
!!
node
.
imgs
?.
length
);
if
(
freeSpace
<
MIN_SIZE
)
{
// There isnt enough space for all the widgets, increase the size of the node
freeSpace
=
MIN_SIZE
;
node
.
size
[
1
]
=
y
+
widgetHeight
+
freeSpace
*
(
multi
.
length
+
(
!!
node
.
imgs
?.
length
));
node
.
graph
.
setDirtyCanvas
(
true
);
}
// Position each of the widgets
for
(
const
w
of
node
.
widgets
)
{
w
.
y
=
y
;
if
(
w
.
type
===
"
customtext
"
)
{
y
+=
freeSpace
;
w
.
computedHeight
=
freeSpace
-
multi
.
length
*
4
;
}
else
if
(
w
.
computeSize
)
{
y
+=
w
.
computeSize
()[
1
]
+
4
;
}
else
{
y
+=
LiteGraph
.
NODE_WIDGET_HEIGHT
+
4
;
}
}
node
.
inputHeight
=
freeSpace
;
function
createIntWidget
(
node
,
inputName
,
inputData
,
app
,
isSeedInput
)
{
const
control
=
inputData
[
1
]?.
control_after_generate
;
if
(
!
isSeedInput
&&
control
)
{
return
seedWidget
(
node
,
inputName
,
inputData
,
app
,
typeof
control
===
"
string
"
?
control
:
undefined
);
}
const
widget
=
{
type
:
"
customtext
"
,
name
,
get
value
()
{
return
this
.
inputEl
.
value
;
},
set
value
(
x
)
{
this
.
inputEl
.
value
=
x
;
let
widgetType
=
isSlider
(
inputData
[
1
][
"
display
"
],
app
);
const
{
val
,
config
}
=
getNumberDefaults
(
inputData
,
1
,
0
,
true
);
Object
.
assign
(
config
,
{
precision
:
0
});
return
{
widget
:
node
.
addWidget
(
widgetType
,
inputName
,
val
,
function
(
v
)
{
const
s
=
this
.
options
.
step
/
10
;
this
.
value
=
Math
.
round
(
v
/
s
)
*
s
;
},
config
),
};
}
function
addMultilineWidget
(
node
,
name
,
opts
,
app
)
{
const
inputEl
=
document
.
createElement
(
"
textarea
"
);
inputEl
.
className
=
"
comfy-multiline-input
"
;
inputEl
.
value
=
opts
.
defaultVal
;
inputEl
.
placeholder
=
opts
.
placeholder
||
name
;
const
widget
=
node
.
addDOMWidget
(
name
,
"
customtext
"
,
inputEl
,
{
getValue
()
{
return
inputEl
.
value
;
},
draw
:
function
(
ctx
,
_
,
widgetWidth
,
y
,
widgetHeight
)
{
if
(
!
this
.
parent
.
inputHeight
)
{
// If we are initially offscreen when created we wont have received a resize event
// Calculate it here instead
computeSize
(
node
.
size
);
}
const
visible
=
app
.
canvas
.
ds
.
scale
>
0.5
&&
this
.
type
===
"
customtext
"
;
const
margin
=
10
;
const
elRect
=
ctx
.
canvas
.
getBoundingClientRect
();
const
transform
=
new
DOMMatrix
()
.
scaleSelf
(
elRect
.
width
/
ctx
.
canvas
.
width
,
elRect
.
height
/
ctx
.
canvas
.
height
)
.
multiplySelf
(
ctx
.
getTransform
())
.
translateSelf
(
margin
,
margin
+
y
);
const
scale
=
new
DOMMatrix
().
scaleSelf
(
transform
.
a
,
transform
.
d
)
Object
.
assign
(
this
.
inputEl
.
style
,
{
transformOrigin
:
"
0 0
"
,
transform
:
scale
,
left
:
`
${
transform
.
a
+
transform
.
e
}
px`
,
top
:
`
${
transform
.
d
+
transform
.
f
}
px`
,
width
:
`
${
widgetWidth
-
(
margin
*
2
)}
px`
,
height
:
`
${
this
.
parent
.
inputHeight
-
(
margin
*
2
)}
px`
,
position
:
"
absolute
"
,
background
:
(
!
node
.
color
)?
''
:
node
.
color
,
color
:
(
!
node
.
color
)?
''
:
'
white
'
,
zIndex
:
app
.
graph
.
_nodes
.
indexOf
(
node
),
});
this
.
inputEl
.
hidden
=
!
visible
;
setValue
(
v
)
{
inputEl
.
value
=
v
;
},
};
widget
.
inputEl
=
document
.
createElement
(
"
textarea
"
);
widget
.
inputEl
.
className
=
"
comfy-multiline-input
"
;
widget
.
inputEl
.
value
=
opts
.
defaultVal
;
widget
.
inputEl
.
placeholder
=
opts
.
placeholder
||
""
;
document
.
addEventListener
(
"
mousedown
"
,
function
(
event
)
{
if
(
!
widget
.
inputEl
.
contains
(
event
.
target
))
{
widget
.
inputEl
.
blur
();
}
});
widget
.
parent
=
node
;
document
.
body
.
appendChild
(
widget
.
inputEl
);
node
.
addCustomWidget
(
widget
);
app
.
canvas
.
onDrawBackground
=
function
()
{
// Draw node isnt fired once the node is off the screen
// if it goes off screen quickly, the input may not be removed
// this shifts it off screen so it can be moved back if the node is visible.
for
(
let
n
in
app
.
graph
.
_nodes
)
{
n
=
graph
.
_nodes
[
n
];
for
(
let
w
in
n
.
widgets
)
{
let
wid
=
n
.
widgets
[
w
];
if
(
Object
.
hasOwn
(
wid
,
"
inputEl
"
))
{
wid
.
inputEl
.
style
.
left
=
-
8000
+
"
px
"
;
wid
.
inputEl
.
style
.
position
=
"
absolute
"
;
}
}
}
};
node
.
onRemoved
=
function
()
{
// When removing this node we need to remove the input from the DOM
for
(
let
y
in
this
.
widgets
)
{
if
(
this
.
widgets
[
y
].
inputEl
)
{
this
.
widgets
[
y
].
inputEl
.
remove
();
}
}
};
widget
.
inputEl
=
inputEl
;
widget
.
onRemove
=
()
=>
{
widget
.
inputEl
?.
remove
();
// Restore original size handler if we are the last
if
(
!--
node
[
MultilineSymbol
])
{
node
.
onResize
=
node
[
MultilineResizeSymbol
];
delete
node
[
MultilineSymbol
];
delete
node
[
MultilineResizeSymbol
];
}
};
if
(
node
[
MultilineSymbol
])
{
node
[
MultilineSymbol
]
++
;
}
else
{
node
[
MultilineSymbol
]
=
1
;
const
onResize
=
(
node
[
MultilineResizeSymbol
]
=
node
.
onResize
);
node
.
onResize
=
function
(
size
)
{
computeSize
(
size
);
// Call original resizer handler
if
(
onResize
)
{
onResize
.
apply
(
this
,
arguments
);
}
};
}
inputEl
.
addEventListener
(
"
input
"
,
()
=>
{
widget
.
callback
?.(
widget
.
value
);
});
return
{
minWidth
:
400
,
minHeight
:
200
,
widget
};
}
...
...
@@ -288,31 +243,26 @@ export const ComfyWidgets = {
},
config
)
};
},
INT
(
node
,
inputName
,
inputData
,
app
)
{
let
widgetType
=
isSlider
(
inputData
[
1
][
"
display
"
],
app
);
const
{
val
,
config
}
=
getNumberDefaults
(
inputData
,
1
,
0
,
true
);
Object
.
assign
(
config
,
{
precision
:
0
});
return
{
widget
:
node
.
addWidget
(
widgetType
,
inputName
,
val
,
function
(
v
)
{
const
s
=
this
.
options
.
step
/
10
;
this
.
value
=
Math
.
round
(
v
/
s
)
*
s
;
},
config
),
};
return
createIntWidget
(
node
,
inputName
,
inputData
,
app
);
},
BOOLEAN
(
node
,
inputName
,
inputData
)
{
let
defaultVal
=
inputData
[
1
][
"
default
"
];
let
defaultVal
=
false
;
let
options
=
{};
if
(
inputData
[
1
])
{
if
(
inputData
[
1
].
default
)
defaultVal
=
inputData
[
1
].
default
;
if
(
inputData
[
1
].
label_on
)
options
[
"
on
"
]
=
inputData
[
1
].
label_on
;
if
(
inputData
[
1
].
label_off
)
options
[
"
off
"
]
=
inputData
[
1
].
label_off
;
}
return
{
widget
:
node
.
addWidget
(
"
toggle
"
,
inputName
,
defaultVal
,
()
=>
{},
{
"
on
"
:
inputData
[
1
].
label_on
,
"
off
"
:
inputData
[
1
].
label_off
}
options
,
)
};
},
...
...
@@ -338,10 +288,14 @@ export const ComfyWidgets = {
if
(
inputData
[
1
]
&&
inputData
[
1
].
default
)
{
defaultValue
=
inputData
[
1
].
default
;
}
return
{
widget
:
node
.
addWidget
(
"
combo
"
,
inputName
,
defaultValue
,
()
=>
{},
{
values
:
type
})
};
const
res
=
{
widget
:
node
.
addWidget
(
"
combo
"
,
inputName
,
defaultValue
,
()
=>
{},
{
values
:
type
})
};
if
(
inputData
[
1
]?.
control_after_generate
)
{
res
.
widget
.
linkedWidgets
=
addValueControlWidgets
(
node
,
res
.
widget
,
undefined
,
undefined
,
inputData
);
}
return
res
;
},
IMAGEUPLOAD
(
node
,
inputName
,
inputData
,
app
)
{
const
imageWidget
=
node
.
widgets
.
find
((
w
)
=>
w
.
name
===
"
image
"
);
const
imageWidget
=
node
.
widgets
.
find
((
w
)
=>
w
.
name
===
(
inputData
[
1
]?.
widget
??
"
image
"
)
)
;
let
uploadWidget
;
function
showImage
(
name
)
{
...
...
@@ -455,9 +409,10 @@ export const ComfyWidgets = {
document
.
body
.
append
(
fileInput
);
// Create the button widget for selecting the files
uploadWidget
=
node
.
addWidget
(
"
button
"
,
"
choose file to upload
"
,
"
image
"
,
()
=>
{
uploadWidget
=
node
.
addWidget
(
"
button
"
,
inputName
,
"
image
"
,
()
=>
{
fileInput
.
click
();
});
uploadWidget
.
label
=
"
choose file to upload
"
;
uploadWidget
.
serialize
=
false
;
// Add handler to check if an image is being dragged over our node
...
...
web/style.css
View file @
c92f3dca
...
...
@@ -409,6 +409,21 @@ dialog::backdrop {
width
:
calc
(
100%
-
10px
);
}
.comfy-img-preview
{
pointer-events
:
none
;
overflow
:
hidden
;
display
:
flex
;
flex-wrap
:
wrap
;
align-content
:
flex-start
;
justify-content
:
center
;
}
.comfy-img-preview
img
{
object-fit
:
contain
;
width
:
var
(
--comfy-img-preview-width
);
height
:
var
(
--comfy-img-preview-height
);
}
/* Search box */
.litegraph.litesearchbox
{
...
...
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment