├── graphapi ├── widget.go ├── link.go ├── prompt.go ├── slot.go ├── group.go ├── simpleapi.go ├── types.go ├── nodeobjects.go ├── node.go ├── graph.go └── properties.go ├── package.go ├── client ├── queueitem.go ├── utils.go ├── dataitems.go ├── promptmessages.go ├── uploaders.go ├── websocket.go ├── wsstatusmessages.go ├── comfyclientrequests.go └── comfyclient.go ├── go.mod ├── .gitignore ├── LICENSE ├── go.sum ├── examples ├── promptfrompng │ └── promptfrompng.go ├── system_stats │ └── comfy_system_stats.go ├── simple_api │ ├── simple_api.go │ └── simple_api.json └── img2img │ ├── comfy_img2img.go │ └── img2img.json └── README.md /graphapi/widget.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | // Widget represents the input points for setting properties within a Node 4 | type Widget struct { 5 | Name *string `json:"name"` 6 | Config *interface{} `json:"config"` 7 | } 8 | -------------------------------------------------------------------------------- /package.go: -------------------------------------------------------------------------------- 1 | // Comfy2go is a Go-based API that acts as a bridge to ComfyUI, a powerful and modular 2 | // stable diffusion GUI and backend. Designed to alleviate the complexities of working directly 3 | // with ComfyUI's intricate API, Comfy2go offers a more user-friendly way to access the advanced 4 | // features and functionalities of ComfyUI. 5 | package comfy2go 6 | -------------------------------------------------------------------------------- /client/queueitem.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "github.com/richinsley/comfy2go/graphapi" 4 | 5 | type QueueItem struct { 6 | PromptID string `json:"prompt_id"` 7 | Number int `json:"number"` 8 | NodeErrors map[string]interface{} `json:"node_errors"` 9 | Messages chan PromptMessage `json:"-"` 10 | Workflow *graphapi.Graph `json:"-"` 11 | } 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/richinsley/comfy2go 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/google/uuid v1.3.0 7 | github.com/gorilla/websocket v1.5.0 8 | github.com/schollz/progressbar/v3 v3.13.1 9 | ) 10 | 11 | require ( 12 | github.com/mattn/go-runewidth v0.0.14 // indirect 13 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 14 | github.com/rivo/uniseg v0.2.0 // indirect 15 | github.com/stretchr/testify v1.8.2 // indirect 16 | golang.org/x/sys v0.6.0 // indirect 17 | golang.org/x/term v0.6.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | *.bak 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | scratch.md 25 | scratch_code.go 26 | TODO.md 27 | 28 | .vscode 29 | .DS_Store 30 | examples/.DS_Store 31 | examples/img2img/*.png 32 | jsontest 33 | 34 | ### jetbrain goland 35 | .idea 36 | -------------------------------------------------------------------------------- /graphapi/link.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | type Link struct { 9 | ID int 10 | OriginID int 11 | OriginSlot int 12 | TargetID int 13 | TargetSlot int 14 | Type string 15 | } 16 | 17 | func (l *Link) UnmarshalJSON(b []byte) error { 18 | var tmp []interface{} 19 | if err := json.Unmarshal(b, &tmp); err != nil { 20 | return err 21 | } 22 | 23 | if len(tmp) != 6 { 24 | return errors.New("wrong number of fields in JSON array") 25 | } 26 | 27 | l.ID = int(tmp[0].(float64)) 28 | l.OriginID = int(tmp[1].(float64)) 29 | l.OriginSlot = int(tmp[2].(float64)) 30 | l.TargetID = int(tmp[3].(float64)) 31 | l.TargetSlot = int(tmp[4].(float64)) 32 | l.Type, _ = tmp[5].(string) 33 | 34 | return nil 35 | } 36 | 37 | func (l *Link) MarshalJSON() ([]byte, error) { 38 | tmp := []interface{}{ 39 | l.ID, 40 | l.OriginID, 41 | l.OriginSlot, 42 | l.TargetID, 43 | l.TargetSlot, 44 | l.Type, 45 | } 46 | 47 | return json.Marshal(tmp) 48 | } 49 | -------------------------------------------------------------------------------- /graphapi/prompt.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | // Prompt is the data that is enqueued to an instance of ComfyUI 4 | type Prompt struct { 5 | ClientID string `json:"client_id"` 6 | Nodes map[int]PromptNode `json:"prompt"` 7 | ExtraData PromptExtraData `json:"extra_data"` 8 | PID string `json:"pid"` 9 | } 10 | 11 | type PromptNode struct { 12 | // Inputs can be one of: 13 | // float64 14 | // string 15 | // []interface{} where: [0] is string of target node 16 | // [1] is float64 (int) of slot index 17 | Inputs map[string]interface{} `json:"inputs"` 18 | ClassType string `json:"class_type"` 19 | } 20 | 21 | type PromptExtraData struct { 22 | PngInfo PromptWorkflow `json:"extra_pnginfo"` 23 | } 24 | 25 | // PromptWorkflow is the original Graph that was used to create the Prompt. 26 | // It is added to generated PNG files such that the information needed to 27 | // recreate the image is available. 28 | type PromptWorkflow struct { 29 | Workflow *Graph `json:"workflow"` 30 | } 31 | -------------------------------------------------------------------------------- /graphapi/slot.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | // Slot represents a connection point within a GraphNode. 4 | // It holds various properties that define the behavior and appearance 5 | // of the connection, such as the name, type, associated widget, and more. 6 | type Slot struct { 7 | Name string `json:"name"` // The name of the slot 8 | CustomType int `json:"-"` 9 | Node *GraphNode `json:"-"` // The node the slot belongs to 10 | Type string `json:"type"` // The type of the data the slot accepts 11 | Link int `json:"link,omitempty"` // Index of the link for an input slot 12 | Links *[]int `json:"links,omitempty"` // Array of links for output slots 13 | Widget *Widget `json:"widget,omitempty"` // Collection of widgets that allow setting properties 14 | Shape *int `json:"shape,omitempty"` 15 | SlotIndex *int `json:"slot_index,omitempty"` // Index of the Slot in relation to other Slots 16 | Property Property `json:"-"` // non-null for inputs that are exported widgets 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rich Insley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /graphapi/group.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | type Group struct { 9 | Title string `json:"title"` 10 | Bounding []float64 `json:"bounding"` 11 | Color string `json:"color"` 12 | } 13 | 14 | func (r *Group) IntersectsOrContains(node *GraphNode) bool { 15 | if len(r.Bounding) != 4 { 16 | slog.Warn("Bounding box does not have exactly 4 elements") 17 | return false 18 | } 19 | 20 | // the geometry is stored differently for nodes and groups 21 | rx := r.Bounding[0] 22 | ry := r.Bounding[1] 23 | rw := r.Bounding[2] 24 | rh := r.Bounding[3] 25 | 26 | // The structure of the pos has changed with a newer version of ComfyUi 27 | var pos []interface{} 28 | switch v := node.Position.(type) { 29 | case []interface{}: 30 | pos = v 31 | case map[string]interface{}: 32 | pos = make([]interface{}, len(v)) 33 | for i := 0; i < len(v); i++ { 34 | pos[i] = v[fmt.Sprintf("%d", i)] 35 | } 36 | default: 37 | slog.Warn("Node position is not of expected type []interface{} or map[string]interface{}", "type", fmt.Sprintf("%T", node.Position)) 38 | return false 39 | } 40 | 41 | nx, ok := pos[0].(float64) 42 | if !ok { 43 | slog.Warn("Node position x is not of type float64") 44 | return false 45 | } 46 | ny, ok := pos[1].(float64) 47 | if !ok { 48 | slog.Warn("Node position y is not of type float64") 49 | return false 50 | } 51 | nw := node.Size.Width 52 | nh := node.Size.Height 53 | 54 | return !(rx > nx+nw || 55 | rx+rw < nx || 56 | ry > ny+nh || 57 | ry+rh < ny) 58 | } 59 | -------------------------------------------------------------------------------- /graphapi/simpleapi.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | type SimpleAPI struct { 4 | Properties map[string]Property 5 | OutputNodes []*GraphNode 6 | } 7 | 8 | func getImageUploader(props []Property) Property { 9 | for _, p := range props { 10 | if p != nil { 11 | if p.TypeString() == "IMAGEUPLOAD" { 12 | return p 13 | } 14 | } 15 | } 16 | return nil 17 | } 18 | 19 | // GetSimpleAPI returns an instance of SimpleAPI 20 | // SimpleAPI is a collection of nodes in the graph that are contained within a group with the given title. 21 | // When title is nil, the default "API" group will be used 22 | func (t *Graph) GetSimpleAPI(title *string) *SimpleAPI { 23 | if title == nil { 24 | defaultAPI := "API" 25 | title = &defaultAPI 26 | } 27 | group := t.GetGroupWithTitle(*title) 28 | if group == nil { 29 | return nil 30 | } 31 | retv := &SimpleAPI{ 32 | Properties: make(map[string]Property), 33 | } 34 | nodes := t.GetNodesInGroup(group) 35 | for _, n := range nodes { 36 | // is the node an output node? Get the *graphapi.NodeObjects for the node 37 | if n.IsOutput { 38 | retv.OutputNodes = append(retv.OutputNodes, n) 39 | } 40 | 41 | props := n.GetPropertiesByIndex() 42 | if len(props) > 0 { 43 | // if a node has an image uploader property, we want that one 44 | uploader := getImageUploader(props) 45 | if uploader != nil { 46 | retv.Properties[n.Title] = uploader 47 | } else { 48 | // otherwise, take the first property in the node 49 | retv.Properties[n.Title] = props[0] 50 | } 51 | } 52 | } 53 | 54 | return retv 55 | } 56 | -------------------------------------------------------------------------------- /client/utils.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "io" 8 | ) 9 | 10 | func GetPngMetadata(r io.Reader) (map[string]string, error) { 11 | header := make([]byte, 8) 12 | _, err := io.ReadFull(r, header) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | if !bytes.Equal(header, []byte{137, 80, 78, 71, 13, 10, 26, 10}) { 18 | return nil, errors.New("not a valid PNG file") 19 | } 20 | 21 | txtChunks := make(map[string]string) 22 | 23 | for { 24 | var length uint32 25 | err = binary.Read(r, binary.BigEndian, &length) 26 | if err == io.EOF { 27 | break 28 | } 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | chunkType := make([]byte, 4) 34 | _, err = io.ReadFull(r, chunkType) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if string(chunkType) == "tEXt" { 40 | chunkData := make([]byte, length) 41 | _, err = io.ReadFull(r, chunkData) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | keywordEnd := bytes.IndexByte(chunkData, 0) 47 | if keywordEnd == -1 { 48 | return nil, errors.New("malformed tEXt chunk") 49 | } 50 | 51 | keyword := string(chunkData[:keywordEnd]) 52 | contentJson := string(chunkData[keywordEnd+1:]) 53 | txtChunks[keyword] = contentJson 54 | } else { 55 | // Skip the chunk data if it's not tEXt 56 | _, err = io.CopyN(io.Discard, r, int64(length)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | } 61 | 62 | // Skip the CRC 63 | _, err = io.CopyN(io.Discard, r, 4) 64 | if err != nil { 65 | return nil, err 66 | } 67 | } 68 | 69 | return txtChunks, nil 70 | } 71 | -------------------------------------------------------------------------------- /client/dataitems.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "github.com/richinsley/comfy2go/graphapi" 4 | 5 | // There may be other DataOutput types. We definitely need a text type 6 | 7 | type DataOutput struct { 8 | Filename string `json:"filename"` 9 | Subfolder string `json:"subfolder"` 10 | Type string `json:"type"` 11 | Text string `json:"-"` // for "text" type data output 12 | } 13 | 14 | type SystemStats struct { 15 | System System `json:"system"` 16 | Devices []GPU `json:"devices"` 17 | } 18 | 19 | type System struct { 20 | OS string `json:"os"` 21 | PythonVersion string `json:"python_version"` 22 | EmbeddedPython bool `json:"embedded_python"` 23 | } 24 | 25 | type GPU struct { 26 | Name string `json:"name"` 27 | Type string `json:"type"` 28 | Index int `json:"index"` 29 | VRAM_Total int64 `json:"vram_total"` 30 | VRAM_Free int64 `json:"vram_free"` 31 | Torch_VRAM_Total int64 `json:"torch_vram_total"` 32 | Torch_VRAM_Free int64 `json:"torch_vram_free"` 33 | } 34 | 35 | type QueueExecInfo struct { 36 | ExecInfo struct { 37 | QueueRemaining int `json:"queue_remaining"` 38 | } `json:"exec_info"` 39 | } 40 | 41 | type PromptHistoryItem struct { 42 | PromptID string 43 | Index int 44 | Graph *graphapi.Graph 45 | Outputs map[int][]DataOutput 46 | } 47 | 48 | type PromptError struct { 49 | Type string `json:"type"` 50 | Message string `json:"message"` 51 | Details string `json:"details"` 52 | ExtraInfo map[string]interface{} `json:"extra_info"` 53 | } 54 | 55 | type PromptErrorMessage struct { 56 | Error PromptError `json:"error"` 57 | NodeErrors []interface{} `json:"node_errors"` 58 | } 59 | -------------------------------------------------------------------------------- /client/promptmessages.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | type PromptMessage struct { 4 | Type string 5 | Message interface{} 6 | } 7 | 8 | // our cast of characters: 9 | // queued 10 | // started 11 | // executing 12 | // progress 13 | // data 14 | // stopped 15 | 16 | type PromptMessageQueued struct { 17 | } 18 | 19 | func (p *PromptMessage) ToPromptMessageQueued() *PromptMessageQueued { 20 | return p.Message.(*PromptMessageQueued) 21 | } 22 | 23 | type PromptMessageStarted struct { 24 | PromptID string `json:"prompt_id"` 25 | } 26 | 27 | func (p *PromptMessage) ToPromptMessageStarted() *PromptMessageStarted { 28 | return p.Message.(*PromptMessageStarted) 29 | } 30 | 31 | type PromptMessageExecuting struct { 32 | NodeID int 33 | Title string 34 | } 35 | 36 | func (p *PromptMessage) ToPromptMessageExecuting() *PromptMessageExecuting { 37 | return p.Message.(*PromptMessageExecuting) 38 | } 39 | 40 | type PromptMessageProgress struct { 41 | Max int 42 | Value int 43 | } 44 | 45 | func (p *PromptMessage) ToPromptMessageProgress() *PromptMessageProgress { 46 | return p.Message.(*PromptMessageProgress) 47 | } 48 | 49 | type PromptMessageData struct { 50 | NodeID int 51 | Data map[string][]DataOutput 52 | } 53 | 54 | func (p *PromptMessage) ToPromptMessageData() *PromptMessageData { 55 | return p.Message.(*PromptMessageData) 56 | } 57 | 58 | type PromptMessageStopped struct { 59 | QueueItem *QueueItem 60 | Exception *PromptMessageStoppedException 61 | } 62 | 63 | type PromptMessageStoppedException struct { 64 | NodeID int 65 | NodeType string 66 | NodeName string 67 | ExceptionMessage string 68 | ExceptionType string 69 | Traceback []string 70 | } 71 | 72 | func (p *PromptMessage) ToPromptMessageStopped() *PromptMessageStopped { 73 | return p.Message.(*PromptMessageStopped) 74 | } 75 | -------------------------------------------------------------------------------- /graphapi/types.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Pos struct { 8 | X float64 9 | Y float64 10 | } 11 | 12 | func (p *Pos) UnmarshalJSON(b []byte) error { 13 | var tmp []interface{} 14 | if err := json.Unmarshal(b, &tmp); err != nil { 15 | return err 16 | } 17 | 18 | for i, v := range tmp { 19 | switch value := v.(type) { 20 | case float64: 21 | if i == 0 { 22 | p.X = value 23 | } else { 24 | p.Y = value 25 | } 26 | case int: 27 | if i == 0 { 28 | p.X = float64(value) 29 | } else { 30 | p.Y = float64(value) 31 | } 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (p *Pos) MarshalJSON() ([]byte, error) { 39 | tmp := []float64{p.X, p.Y} 40 | return json.Marshal(tmp) 41 | } 42 | 43 | type Size struct { 44 | Width float64 45 | Height float64 46 | } 47 | 48 | func (s *Size) UnmarshalJSON(b []byte) error { 49 | // First try to unmarshal as array 50 | var tmpArr []interface{} 51 | if err := json.Unmarshal(b, &tmpArr); err == nil && len(tmpArr) == 2 { 52 | for i, v := range tmpArr { 53 | switch value := v.(type) { 54 | case float64: 55 | if i == 0 { 56 | s.Width = value 57 | } else { 58 | s.Height = value 59 | } 60 | case int: 61 | if i == 0 { 62 | s.Width = float64(value) 63 | } else { 64 | s.Height = float64(value) 65 | } 66 | } 67 | } 68 | return nil 69 | } 70 | 71 | // If not array, try to unmarshal as map 72 | var tmpMap map[string]interface{} 73 | if err := json.Unmarshal(b, &tmpMap); err != nil { 74 | return err 75 | } 76 | 77 | for k, v := range tmpMap { 78 | switch value := v.(type) { 79 | case float64: 80 | if k == "0" { 81 | s.Width = value 82 | } else { 83 | s.Height = value 84 | } 85 | case int: 86 | if k == "0" { 87 | s.Width = float64(value) 88 | } else { 89 | s.Height = float64(value) 90 | } 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // func (s *Size) MarshalJSON() ([]byte, error) { 98 | // tmp := map[string]float64{ 99 | // "0": s.Width, 100 | // "1": s.Height, 101 | // } 102 | // return json.Marshal(tmp) 103 | // } 104 | 105 | // it seems the json code can have either an array of values, or a dictionary of values 106 | // when marshaling, we'll always output as an array. 107 | func (s *Size) MarshalJSON() ([]byte, error) { 108 | tmp := []float64{s.Width, s.Height} 109 | return json.Marshal(tmp) 110 | } 111 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 5 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 6 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 7 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 8 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 9 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 10 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 11 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 12 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 13 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 17 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 18 | github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= 19 | github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 22 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 23 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 24 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 25 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 26 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 27 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 28 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 32 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /client/uploaders.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "image" 8 | "image/png" 9 | "io" 10 | "mime/multipart" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | 15 | "github.com/richinsley/comfy2go/graphapi" 16 | ) 17 | 18 | type ImageType string 19 | 20 | const ( 21 | InputImageType ImageType = "input" 22 | TempImageType ImageType = "temp" 23 | OutputImageType ImageType = "output" 24 | ) 25 | 26 | func (c *ComfyClient) UploadFileFromReader(r io.Reader, filename string, overwrite bool, filetype ImageType, subfolder string, targetProperty *graphapi.ImageUploadProperty) (string, error) { 27 | // Create a buffer to store the request body 28 | var requestBody bytes.Buffer 29 | 30 | // Create a multipart writer to wrap the file (like FormData) 31 | writer := multipart.NewWriter(&requestBody) 32 | 33 | // Create a form-file for the image and copy the image data into it 34 | formFile, err := writer.CreateFormFile("image", filename) 35 | if err != nil { 36 | return "", err 37 | } 38 | _, err = io.Copy(formFile, r) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | // Add the overwrite field 44 | _ = writer.WriteField("overwrite", fmt.Sprintf("%v", overwrite)) 45 | 46 | // Add the file type field 47 | _ = writer.WriteField("type", fmt.Sprintf("%v", filetype)) 48 | 49 | // Add the subfolder field 50 | if subfolder != "" { 51 | _ = writer.WriteField("subfolder", fmt.Sprintf("%v", subfolder)) 52 | } 53 | 54 | // Close the writer to finalize the body content 55 | writer.Close() 56 | 57 | // Create the request 58 | req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/upload/image", c.serverBaseAddress), &requestBody) 59 | if err != nil { 60 | return "", err 61 | } 62 | req.Header.Set("Content-Type", writer.FormDataContentType()) 63 | 64 | // Execute the request 65 | resp, err := c.httpclient.Do(req) 66 | if err != nil { 67 | return "", err 68 | } 69 | defer resp.Body.Close() 70 | 71 | // Check the response 72 | if resp.StatusCode == 200 { 73 | // Decode the JSON response 74 | var data map[string]interface{} 75 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 76 | return "", err 77 | } 78 | 79 | // Get the image name from the response 80 | name, ok := data["name"].(string) 81 | if !ok { 82 | return "", fmt.Errorf("invalid response format") 83 | } 84 | 85 | // if we were provided an ImageUploadProperty target, set the property value 86 | if targetProperty != nil { 87 | targetProperty.SetFilename(name) 88 | } 89 | 90 | // return the actual name that was chosen from the server side. It may be different 91 | // from the filename we provided. the data field also contains the given type and subfolder, 92 | // but we should already know that 93 | return name, nil 94 | } else { 95 | return "", fmt.Errorf("error: %d - %s", resp.StatusCode, resp.Status) 96 | } 97 | } 98 | 99 | func (c *ComfyClient) UploadFileFromPath(filePath string, overwrite bool, filetype ImageType, subfolder string, targetProperty *graphapi.ImageUploadProperty) (string, error) { 100 | // Open the file 101 | file, err := os.Open(filePath) 102 | if err != nil { 103 | return "", err 104 | } 105 | defer file.Close() 106 | 107 | return c.UploadFileFromReader(file, filepath.Base(filePath), overwrite, filetype, subfolder, targetProperty) 108 | } 109 | 110 | func (c *ComfyClient) UploadImage(img image.Image, filename string, overwrite bool, filetype ImageType, subfolder string, targetProperty *graphapi.ImageUploadProperty) (string, error) { 111 | // Encode the image to PNG format into a bytes buffer 112 | var buffer bytes.Buffer 113 | if err := png.Encode(&buffer, img); err != nil { 114 | return "", err 115 | } 116 | 117 | // Get the bytes from the buffer 118 | byteArray := buffer.Bytes() 119 | 120 | // Create an io.Reader from the bytes 121 | reader := bytes.NewReader(byteArray) 122 | return c.UploadFileFromReader(reader, filepath.Base(filename), overwrite, filetype, subfolder, targetProperty) 123 | } 124 | -------------------------------------------------------------------------------- /examples/promptfrompng/promptfrompng.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/richinsley/comfy2go/client" 10 | "github.com/schollz/progressbar/v3" 11 | ) 12 | 13 | // process CLI arguments 14 | func procCLI() (string, int, string) { 15 | serverAddress := flag.String("address", "localhost", "Server address") 16 | serverPort := flag.Int("port", 8188, "Server port") 17 | flag.Usage = func() { 18 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 19 | fmt.Printf(" %s [OPTIONS] filename", os.Args[0]) 20 | fmt.Println("\nOptions:") 21 | flag.PrintDefaults() 22 | fmt.Println("\nfilename: Path to workflow json file") 23 | } 24 | flag.Parse() 25 | 26 | // Check for required filename argument 27 | if len(flag.Args()) != 1 { 28 | flag.Usage() 29 | fmt.Println("Provide a PNG file with a workflow") 30 | os.Exit(1) 31 | } 32 | filename := flag.Arg(0) 33 | return *serverAddress, *serverPort, filename 34 | } 35 | 36 | func main() { 37 | clientaddr, clientport, workflow := procCLI() 38 | 39 | callbacks := &client.ComfyClientCallbacks{ 40 | ClientQueueCountChanged: func(c *client.ComfyClient, queuecount int) { 41 | log.Printf("Client %s at %s Queue size: %d", c.ClientID(), clientaddr, queuecount) 42 | }, 43 | } 44 | 45 | // create a client 46 | c := client.NewComfyClient(clientaddr, clientport, callbacks) 47 | 48 | // the client needs to be in an initialized state before usage 49 | if !c.IsInitialized() { 50 | log.Printf("Initialize Client with ID: %s\n", c.ClientID()) 51 | err := c.Init() 52 | if err != nil { 53 | log.Println("Error initializing client:", err) 54 | os.Exit(1) 55 | } 56 | } 57 | 58 | // create a graph from the png file 59 | graph, _, err := c.NewGraphFromPNGFile(workflow) 60 | if err != nil { 61 | log.Println("Failed to get workflow graph from png file:", err) 62 | os.Exit(1) 63 | } 64 | 65 | // queue the prompt and get the resulting image 66 | item, err := c.QueuePrompt(graph) 67 | if err != nil { 68 | log.Println("Failed to queue prompt:", err) 69 | os.Exit(1) 70 | } 71 | 72 | // we'll provide a progress bar 73 | var bar *progressbar.ProgressBar = nil 74 | 75 | // continuously read messages from the QueuedItem until we get the "stopped" message type 76 | var currentNodeTitle string 77 | for continueLoop := true; continueLoop; { 78 | msg := <-item.Messages 79 | switch msg.Type { 80 | case "started": 81 | qm := msg.ToPromptMessageStarted() 82 | log.Printf("Start executing prompt ID %s\n", qm.PromptID) 83 | case "executing": 84 | bar = nil 85 | qm := msg.ToPromptMessageExecuting() 86 | // store the node's title so we can use it in the progress bar 87 | currentNodeTitle = qm.Title 88 | log.Printf("Executing Node: %d\n", qm.NodeID) 89 | case "progress": 90 | // update our progress bar 91 | qm := msg.ToPromptMessageProgress() 92 | if bar == nil { 93 | bar = progressbar.Default(int64(qm.Max), currentNodeTitle) 94 | } 95 | bar.Set(qm.Value) 96 | case "stopped": 97 | // if we were stopped for an exception, display the exception message 98 | qm := msg.ToPromptMessageStopped() 99 | if qm.Exception != nil { 100 | log.Println(qm.Exception) 101 | os.Exit(1) 102 | } 103 | continueLoop = false 104 | case "data": 105 | qm := msg.ToPromptMessageData() 106 | // data objects have the fields: Filename, Subfolder, Type 107 | // * Subfolder is the subfolder in the output directory 108 | // * Type is the type of the image temp/ 109 | for k, v := range qm.Data { 110 | if k == "images" || k == "gifs" { 111 | for _, output := range v { 112 | img_data, err := c.GetImage(output) 113 | if err != nil { 114 | log.Println("Failed to get image:", err) 115 | os.Exit(1) 116 | } 117 | f, err := os.Create(output.Filename) 118 | if err != nil { 119 | log.Println("Failed to write image:", err) 120 | os.Exit(1) 121 | } 122 | f.Write(*img_data) 123 | f.Close() 124 | log.Println("Got data: ", output.Filename) 125 | } 126 | } 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /examples/system_stats/comfy_system_stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/richinsley/comfy2go/client" 10 | ) 11 | 12 | // process CLI arguments 13 | func procCLI() (string, int) { 14 | serverAddress := flag.String("address", "localhost", "Server address") 15 | serverPort := flag.Int("port", 8188, "Server port") 16 | flag.Usage = func() { 17 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 18 | fmt.Printf(" %s [OPTIONS] filename", os.Args[0]) 19 | fmt.Println("\nOptions:") 20 | flag.PrintDefaults() 21 | fmt.Println("\nfilename: Path to workflow json file") 22 | } 23 | flag.Parse() 24 | return *serverAddress, *serverPort 25 | } 26 | 27 | func displayAvailableNodes(c *client.ComfyClient) { 28 | object_infos, err := c.GetObjectInfos() 29 | if err != nil { 30 | log.Println("Error decoding Object Infos:", err) 31 | os.Exit(1) 32 | } 33 | 34 | log.Println("Available Nodes:") 35 | for _, n := range object_infos.Objects { 36 | log.Printf("\tNode Name: \"%s\"\n", n.DisplayName) 37 | props := n.GetSettableProperties() 38 | log.Printf("\t\tProperties:\n") 39 | for _, p := range props { 40 | log.Printf("\t\t\t\"%s\"\tType: [%s]\n", p.Name(), p.TypeString()) 41 | if p.TypeString() == "COMBO" { 42 | c, _ := p.ToComboProperty() 43 | for _, combo_item := range c.Values { 44 | log.Printf("\t\t\t\t\"%s\"\n", combo_item) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | // displayExtensions gets the installed extensions 52 | func displayExtensions(c *client.ComfyClient) { 53 | extensions, err := c.GetExtensions() 54 | if err != nil { 55 | log.Println("Error decoding System Stats:", err) 56 | os.Exit(1) 57 | } 58 | log.Println("Instaled extensions:") 59 | for _, e := range extensions { 60 | log.Printf("\t%s\n", e) 61 | } 62 | log.Println() 63 | } 64 | 65 | // displaySystemStats gets the system statistics for the client 66 | func displaySystemStats(c *client.ComfyClient) { 67 | // Get System Stats 68 | system_info, err := c.GetSystemStats() 69 | if err != nil { 70 | log.Println("Error decoding System Stats:", err) 71 | os.Exit(1) 72 | } 73 | log.Println("System Stats:") 74 | log.Printf("\tOS: %s\n", system_info.System.OS) 75 | log.Printf("\tPython Version: %s\n", system_info.System.PythonVersion) 76 | log.Println("\tDevices:") 77 | for _, dev := range system_info.Devices { 78 | log.Printf("\t\tIndex: %d\n", dev.Index) 79 | log.Printf("\t\tName: %s\n", dev.Name) 80 | log.Printf("\t\tType: %s\n", dev.Type) 81 | log.Printf("\t\tVRAM Total %d\n", dev.VRAM_Total) 82 | log.Printf("\t\tVRAM Free %d\n", dev.VRAM_Free) 83 | log.Printf("\t\tTorch VRAM Total %d\n", dev.Torch_VRAM_Total) 84 | log.Printf("\t\tTorch VRAM Free %d\n", dev.Torch_VRAM_Free) 85 | } 86 | log.Println() 87 | } 88 | 89 | // displayPromptHistory gets the prompt history from a client and displays them 90 | func displayPromptHistory(c *client.ComfyClient) { 91 | // Get prompt history in order 92 | prompt_history, err := c.GetPromptHistoryByIndex() 93 | if err != nil { 94 | log.Println("Error decoding Prompt Histories:", err) 95 | os.Exit(1) 96 | } 97 | 98 | // iterate over prompt history items and display 99 | log.Println("Prompt History:") 100 | for _, p := range prompt_history { 101 | log.Printf("\tPrompt index: %d Prompt ID: %s\n", p.Index, p.PromptID) 102 | log.Println("\tOutput nodes:") 103 | for nodeid, out := range p.Outputs { 104 | log.Printf("\t\tNode ID %d\n", nodeid) 105 | for _, img_data := range out { 106 | log.Printf("\t\t\tFilename: %s Type: \"%s\" Subfolder: %s\n", img_data.Filename, img_data.Type, img_data.Subfolder) 107 | } 108 | } 109 | } 110 | log.Println() 111 | } 112 | 113 | func main() { 114 | clientaddr, clientport := procCLI() 115 | 116 | // create a client 117 | c := client.NewComfyClient(clientaddr, clientport, nil) 118 | 119 | // Becuase we are not going to be creating or queuing prompts, we do not 120 | // need to initialize the client. 121 | 122 | // display system stats 123 | displaySystemStats(c) 124 | 125 | // display installed extensions 126 | displayExtensions(c) 127 | 128 | // display prompt history 129 | displayPromptHistory(c) 130 | 131 | // display available nodes with thier properties 132 | displayAvailableNodes(c) 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comfy2go 2 | 3 | Comfy2go is a Go-based API that acts as a bridge to ComfyUI, a powerful and modular stable diffusion GUI and backend. Designed to alleviate the complexities of working directly with ComfyUI's intricate API, Comfy2go offers a more user-friendly way to access the advanced features and functionalities of ComfyUI. 4 | 5 | 6 | ## Table of Contents 7 | 8 | - [Overview](#overview) 9 | - [Installation](#installation) 10 | - [Usage](#usage) 11 | 12 | ## Overview 13 | 14 | Comfy2go allows for developers to harness ComfyUI's powerful features in a more accessible way. Comfy2go is comprised of two main parts: 15 | 16 | ### GraphAPI 17 | The GraphAPI approximates the functionality of ComfyUI's front-end graph-based pipeline. While it does not allow for creating or editing existing workflows, it does allow for quickly finding, and setting the various inputs of each node in a workflow. 18 | 19 | ### ClientAPI 20 | The ClientAPI interoperates with the ComfyUI backend, offering: 21 | - Backend system statistics 22 | - Concurrent access to mulitple instances of ComfyUI 23 | - Image and mask uploading/downloading 24 | - Creating and queuing prompts from GraphAPI workflows 25 | - Managing Queues 26 | - Retreival of Prompt histories 27 | - Loading workflows from PNG 28 | - and quite a bit more 29 | 30 | ## Installation 31 | First, use 'go get' to install the latest version of the library. 32 | ```bash 33 | go get -u github.com/richinsley/comfy2go@latest 34 | ``` 35 | Next, include Comfy2go client (and optionally the graph) APIs in your application: 36 | ```go 37 | import "github.com/richinsley/comfy2go/client" 38 | import "github.com/richinsley/comfy2go/graphapi" 39 | ``` 40 | ## Usage 41 | An **IMPORTANT** note is that Comfy2go works with full ComfyUI workflows, not workflows saved with "Save (API Format)" 42 | 43 | #### Load a workflow from a png and queue it to a ComfyUI instance 44 | ```go 45 | package main 46 | 47 | import ( 48 | "log" 49 | "os" 50 | 51 | "github.com/richinsley/comfy2go/client" 52 | ) 53 | 54 | func main() { 55 | clientaddr := "127.0.0.1" 56 | clientport := 8188 57 | pngpath := "my_cool_workflow.png" 58 | 59 | // create a new ComgyGo client 60 | c := client.NewComfyClient(clientaddr, clientport, nil) 61 | 62 | // the ComgyGo client needs to be in an initialized state before 63 | // we can create and queue graphs 64 | if !c.IsInitialized() { 65 | log.Printf("Initialize Client with ID: %s\n", c.ClientID()) 66 | err := c.Init() 67 | if err != nil { 68 | log.Println("Error initializing client:", err) 69 | os.Exit(1) 70 | } 71 | } 72 | 73 | // create a graph from the png file 74 | graph, _, err := c.NewGraphFromPNGFile(pngpath) 75 | if err != nil { 76 | log.Println("Failed to get workflow graph from png file:", err) 77 | os.Exit(1) 78 | } 79 | 80 | // queue the prompt and get the resulting image 81 | item, err := c.QueuePrompt(graph) 82 | if err != nil { 83 | log.Println("Failed to queue prompt:", err) 84 | os.Exit(1) 85 | } 86 | 87 | // continuously read messages from the QueuedItem until we get the "stopped" message type 88 | for continueLoop := true; continueLoop; { 89 | msg := <-item.Messages 90 | switch msg.Type { 91 | case "stopped": 92 | // if we were stopped for an exception, display the exception message 93 | qm := msg.ToPromptMessageStopped() 94 | if qm.Exception != nil { 95 | log.Println(qm.Exception) 96 | os.Exit(1) 97 | } 98 | continueLoop = false 99 | case "data": 100 | qm := msg.ToPromptMessageData() 101 | // data objects have the fields: Filename, Subfolder, Type 102 | // * Subfolder is the subfolder in the output directory 103 | // * Type is the type of the image temp/ 104 | for k, v := range qm.Data { 105 | if k == "images" || k == "gifs" { 106 | for _, output := range v { 107 | img_data, err := c.GetImage(output) 108 | if err != nil { 109 | log.Println("Failed to get image:", err) 110 | os.Exit(1) 111 | } 112 | f, err := os.Create(output.Filename) 113 | if err != nil { 114 | log.Println("Failed to write image:", err) 115 | os.Exit(1) 116 | } 117 | f.Write(*img_data) 118 | f.Close() 119 | log.Println("Got image: ", output.Filename) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | ``` 128 | -------------------------------------------------------------------------------- /client/websocket.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "math" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | // Callback interface for handling incoming WebSocket messages 14 | type WebSocketCallback interface { 15 | OnMessage(message string) 16 | } 17 | 18 | type WebSocketConnection struct { 19 | WebSocketURL string 20 | Conn *websocket.Conn 21 | ConnectionDone chan bool 22 | IsConnected bool 23 | MaxRetry int 24 | RetryCount int 25 | ManagerStarted bool 26 | mu sync.Mutex // For thread-safe access to the WebSocket connection 27 | Callback WebSocketCallback 28 | 29 | // Exponential backoff configuration 30 | BaseDelay time.Duration // The initial delay, e.g., 1 second 31 | MaxDelay time.Duration // The maximum delay, e.g., 1 minute 32 | Dialer websocket.Dialer 33 | } 34 | 35 | // ConnectWithManager connects to the WebSocket using a connection manager 36 | // timeoutSeconds is the maximum time to wait for a successful connection (0 for no timeout) 37 | func (w *WebSocketConnection) ConnectWithManager(timeoutSeconds int) error { 38 | // time.Duration 39 | // Channel to signal successful connection 40 | connected := make(chan bool, 1) 41 | // Channel for connection attempts (ensures connect() is not called concurrently) 42 | attemptConnect := make(chan bool, 1) 43 | attemptConnect <- true // Trigger the first connection attempt immediately 44 | 45 | go func() { 46 | retries := 0 47 | for { 48 | select { 49 | case <-attemptConnect: 50 | err := w.connect() 51 | if err != nil { 52 | slog.Error("Connection attempt failed: ", "error", err) 53 | w.IsConnected = false 54 | 55 | // Check if the maximum number of retries has been reached 56 | retries++ 57 | if retries > w.MaxRetry { 58 | slog.Error(fmt.Sprintf("Maximum number of retries reached (%d)", w.MaxRetry)) 59 | close(connected) // Signal that the connection failed 60 | return 61 | } 62 | 63 | // Wait a bit before retrying to connect 64 | time.AfterFunc(w.getReconnectDelay(), func() { 65 | attemptConnect <- true 66 | }) 67 | } else { 68 | w.IsConnected = true 69 | close(connected) // Signal that the connection was successful 70 | w.handleMessages() 71 | return // Exit the goroutine once connected 72 | } 73 | case <-w.ConnectionDone: 74 | // Handle graceful shutdown if needed 75 | return 76 | } 77 | } 78 | }() 79 | 80 | // Block until either a successful connection or timeout 81 | if timeoutSeconds > 0 { 82 | timeout := time.Duration(timeoutSeconds) * time.Second 83 | select { 84 | case <-connected: 85 | // Connection was successful before the timeout 86 | return nil 87 | case <-time.After(timeout): 88 | // Timeout occurred before a successful connection 89 | return fmt.Errorf("connection timeout after %v", timeout) 90 | } 91 | } else if timeoutSeconds < 0 { 92 | // wait indefinitely 93 | <-connected 94 | } 95 | 96 | return nil 97 | } 98 | 99 | // Initial connection logic with exponential backoff for reconnections 100 | func (w *WebSocketConnection) connect() error { 101 | conn, _, err := w.Dialer.Dial(w.WebSocketURL, nil) 102 | if err != nil { 103 | slog.Error("Failed to connect: ", "error", err) 104 | return err 105 | } 106 | 107 | w.Conn = conn 108 | return nil 109 | } 110 | 111 | func (w *WebSocketConnection) Ping() error { 112 | // Attempt to send a ping message 113 | err := w.Conn.WriteMessage(websocket.PingMessage, nil) 114 | if err != nil { 115 | return err 116 | } 117 | return nil 118 | } 119 | 120 | // Handle incoming WebSocket messages 121 | func (w *WebSocketConnection) handleMessages() { 122 | defer func() { 123 | w.Conn.Close() 124 | w.ConnectionDone <- true 125 | }() 126 | for { 127 | _, message, err := w.Conn.ReadMessage() 128 | if err != nil { 129 | slog.Warn(fmt.Sprintf("Read error: %v", err)) 130 | break 131 | } 132 | if w.Callback != nil { 133 | w.Callback.OnMessage(string(message)) 134 | } 135 | } 136 | } 137 | 138 | // exponential backoff calculation 139 | func (w *WebSocketConnection) getReconnectDelay() time.Duration { 140 | // Calculate the delay as BaseDelay * 2^(RetryCount), capped at MaxDelay 141 | delay := w.BaseDelay * time.Duration(math.Pow(2, float64(w.RetryCount))) 142 | if delay > w.MaxDelay { 143 | delay = w.MaxDelay 144 | } 145 | w.RetryCount++ // Increment the retry counter for the next attempt 146 | return delay 147 | } 148 | 149 | func (w *WebSocketConnection) LockRead() { 150 | w.mu.Lock() 151 | } 152 | 153 | func (w *WebSocketConnection) UnlockRead() { 154 | w.mu.Unlock() 155 | } 156 | -------------------------------------------------------------------------------- /examples/simple_api/simple_api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/richinsley/comfy2go/client" 10 | "github.com/schollz/progressbar/v3" 11 | ) 12 | 13 | // process CLI arguments 14 | func procCLI() (string, int, string) { 15 | serverAddress := flag.String("address", "localhost", "Server address") 16 | serverPort := flag.Int("port", 8188, "Server port") 17 | flag.Usage = func() { 18 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 19 | fmt.Printf(" %s [OPTIONS] filename", os.Args[0]) 20 | fmt.Println("\nOptions:") 21 | flag.PrintDefaults() 22 | fmt.Println("\nfilename: Path to workflow json file") 23 | } 24 | flag.Parse() 25 | 26 | // Check for required filename argument 27 | if len(flag.Args()) != 1 { 28 | flag.Usage() 29 | os.Exit(1) 30 | } 31 | filename := flag.Arg(0) 32 | return *serverAddress, *serverPort, filename 33 | } 34 | 35 | func main() { 36 | clientaddr, clientport, workflow := procCLI() 37 | 38 | // callbacks can be used respond to QueuedItem updates, or client status changes 39 | callbacks := &client.ComfyClientCallbacks{ 40 | ClientQueueCountChanged: func(c *client.ComfyClient, queuecount int) { 41 | log.Printf("Client %s at %s Queue size: %d", c.ClientID(), clientaddr, queuecount) 42 | }, 43 | QueuedItemStarted: func(c *client.ComfyClient, qi *client.QueueItem) { 44 | log.Printf("Queued item %s started\n", qi.PromptID) 45 | }, 46 | QueuedItemStopped: func(cc *client.ComfyClient, qi *client.QueueItem, reason client.QueuedItemStoppedReason) { 47 | log.Printf("Queued item %s stopped\n", qi.PromptID) 48 | }, 49 | QueuedItemDataAvailable: func(cc *client.ComfyClient, qi *client.QueueItem, pmd *client.PromptMessageData) { 50 | log.Printf("image data available:\n") 51 | }, 52 | } 53 | 54 | // create a client 55 | c := client.NewComfyClient(clientaddr, clientport, callbacks) 56 | 57 | // the client needs to be in an initialized state before usage 58 | if !c.IsInitialized() { 59 | log.Printf("Initialize Client with ID: %s\n", c.ClientID()) 60 | err := c.Init() 61 | if err != nil { 62 | log.Println("Error initializing client:", err) 63 | os.Exit(1) 64 | } 65 | } 66 | 67 | // load the workflow 68 | graph, _, err := c.NewGraphFromJsonFile(workflow) 69 | if err != nil { 70 | log.Println("Error loading graph JSON:", err) 71 | os.Exit(1) 72 | } 73 | 74 | // Get the nodes that are within the "API" Group. GetSimpleAPI takes each 75 | // node and exposes it's first (and only it's first) property, with the title of the node as the key 76 | // in the Properties field. 77 | simple_api := graph.GetSimpleAPI(nil) 78 | width := simple_api.Properties["Width"] 79 | height := simple_api.Properties["Height"] 80 | positive := simple_api.Properties["Positive"] 81 | negative := simple_api.Properties["Negative"] 82 | width.SetValue(1024) 83 | height.SetValue(1024) 84 | positive.SetValue("a dive bar, dimly lit, zombies, dancing, mosh pit, (kittens:1.5)") 85 | negative.SetValue("text, watermark") 86 | 87 | // or we can set it directly 88 | simple_api.Properties["Seed"].SetValue(2290222) 89 | 90 | // queue the prompt and get the resulting image 91 | item, err := c.QueuePrompt(graph) 92 | if err != nil { 93 | log.Println("Failed to queue prompt:", err) 94 | os.Exit(1) 95 | } 96 | 97 | // we'll provide a progress bar 98 | var bar *progressbar.ProgressBar = nil 99 | 100 | // continuously read messages from the QueuedItem until we get the "stopped" message type 101 | var currentNodeTitle string 102 | for continueLoop := true; continueLoop; { 103 | msg := <-item.Messages 104 | switch msg.Type { 105 | case "started": 106 | qm := msg.ToPromptMessageStarted() 107 | log.Printf("Start executing prompt ID %s\n", qm.PromptID) 108 | case "executing": 109 | bar = nil 110 | qm := msg.ToPromptMessageExecuting() 111 | // store the node's title so we can use it in the progress bar 112 | currentNodeTitle = qm.Title 113 | log.Printf("Executing Node: %d\n", qm.NodeID) 114 | case "progress": 115 | // update our progress bar 116 | qm := msg.ToPromptMessageProgress() 117 | if bar == nil { 118 | bar = progressbar.Default(int64(qm.Max), currentNodeTitle) 119 | } 120 | bar.Set(qm.Value) 121 | case "stopped": 122 | // if we were stopped for an exception, display the exception message 123 | qm := msg.ToPromptMessageStopped() 124 | if qm.Exception != nil { 125 | log.Println(qm.Exception) 126 | os.Exit(1) 127 | } 128 | continueLoop = false 129 | case "data": 130 | qm := msg.ToPromptMessageData() 131 | // data objects have the fields: Filename, Subfolder, Type 132 | // * Subfolder is the subfolder in the output directory 133 | // * Type is the type of the image temp/ 134 | for k, v := range qm.Data { 135 | if k == "images" || k == "gifs" { 136 | for _, output := range v { 137 | img_data, err := c.GetImage(output) 138 | if err != nil { 139 | log.Println("Failed to get image:", err) 140 | os.Exit(1) 141 | } 142 | f, err := os.Create(output.Filename) 143 | if err != nil { 144 | log.Println("Failed to write image:", err) 145 | os.Exit(1) 146 | } 147 | f.Write(*img_data) 148 | f.Close() 149 | log.Println("Got data: ", output.Filename) 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /examples/img2img/comfy_img2img.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | "log" 10 | "math" 11 | "os" 12 | 13 | "github.com/richinsley/comfy2go/client" 14 | "github.com/schollz/progressbar/v3" 15 | ) 16 | 17 | // process CLI arguments 18 | func procCLI() (string, int) { 19 | serverAddress := flag.String("address", "localhost", "Server address") 20 | serverPort := flag.Int("port", 8188, "Server port") 21 | flag.Usage = func() { 22 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 23 | fmt.Printf(" %s [OPTIONS] filename", os.Args[0]) 24 | fmt.Println("\nOptions:") 25 | flag.PrintDefaults() 26 | fmt.Println("\nfilename: Path to workflow json file") 27 | } 28 | flag.Parse() 29 | 30 | return *serverAddress, *serverPort 31 | } 32 | 33 | func main() { 34 | clientaddr, clientport := procCLI() 35 | 36 | callbacks := &client.ComfyClientCallbacks{ 37 | ClientQueueCountChanged: func(c *client.ComfyClient, queuecount int) { 38 | log.Printf("Client %s at %s Queue size: %d", c.ClientID(), clientaddr, queuecount) 39 | }, 40 | } 41 | 42 | // create a client 43 | c := client.NewComfyClient(clientaddr, clientport, callbacks) 44 | 45 | // the client needs to be in an initialized state before usage 46 | if !c.IsInitialized() { 47 | log.Printf("Initialize Client with ID: %s\n", c.ClientID()) 48 | err := c.Init() 49 | if err != nil { 50 | log.Println("Error initializing client:", err) 51 | os.Exit(1) 52 | } 53 | } 54 | 55 | // load the workflow 56 | graph, _, err := c.NewGraphFromJsonFile("img2img.json") 57 | if err != nil { 58 | log.Println("Error loading graph JSON:", err) 59 | os.Exit(1) 60 | } 61 | 62 | // Create a simple 512x512 image of some rolling green hills 63 | img := image.NewRGBA(image.Rect(0, 0, 512, 512)) 64 | 65 | // Draw the blue sky 66 | skyColor := color.RGBA{135, 206, 250, 255} // Sky blue color 67 | draw.Draw(img, image.Rect(0, 0, 512, 256), &image.Uniform{skyColor}, image.Point{}, draw.Src) 68 | 69 | // Draw green hills 70 | mountainColor := color.RGBA{34, 139, 34, 255} // Forest green color 71 | for x := 0; x < 512; x++ { 72 | y := 256 + int(50*math.Sin(float64(x)*0.02)) // Sine wave for hills 73 | draw.Draw(img, image.Rect(x, y, x+1, 512), &image.Uniform{mountainColor}, image.Point{}, draw.Src) 74 | } 75 | 76 | // upload our image. The workflow should only have one "Load Image" 77 | loadImageNode := graph.GetFirstNodeWithTitle("Load Image") 78 | if loadImageNode == nil { 79 | log.Println("missing Load Image node") 80 | } else { 81 | // get the property interface for "choose file to upload" or the alias "file" 82 | prop := loadImageNode.GetPropertyWithName("choose file to upload") 83 | if prop == nil { 84 | log.Println("missing property \"choose file to upload\"") 85 | } else { 86 | // the ImageUploadProperty value is not directly settable. We need to pass the property to the call to client.UploadImage 87 | uploadprop, _ := prop.ToImageUploadProperty() 88 | 89 | // because we set it to not overwrite existing, the returned filename may 90 | // be different than the one we provided 91 | _, err := c.UploadImage(img, "mountains.png", false, client.InputImageType, "", uploadprop) 92 | if err != nil { 93 | log.Println("Uploading image:", err) 94 | os.Exit(1) 95 | } 96 | } 97 | } 98 | 99 | // queue the prompt and get the resulting image 100 | item, err := c.QueuePrompt(graph) 101 | if err != nil { 102 | log.Println("Failed to queue prompt:", err) 103 | os.Exit(1) 104 | } 105 | 106 | // we'll provide a progress bar 107 | var bar *progressbar.ProgressBar = nil 108 | 109 | // continuously read messages from the QueuedItem until we get the "stopped" message type 110 | var currentNodeTitle string 111 | for continueLoop := true; continueLoop; { 112 | msg := <-item.Messages 113 | switch msg.Type { 114 | case "started": 115 | qm := msg.ToPromptMessageStarted() 116 | log.Printf("Start executing prompt ID %s\n", qm.PromptID) 117 | case "executing": 118 | bar = nil 119 | qm := msg.ToPromptMessageExecuting() 120 | // store the node's title so we can use it in the progress bar 121 | currentNodeTitle = qm.Title 122 | log.Printf("Executing Node: %d\n", qm.NodeID) 123 | case "progress": 124 | // update our progress bar 125 | qm := msg.ToPromptMessageProgress() 126 | if bar == nil { 127 | bar = progressbar.Default(int64(qm.Max), currentNodeTitle) 128 | } 129 | bar.Set(qm.Value) 130 | case "stopped": 131 | // if we were stopped for an exception, display the exception message 132 | qm := msg.ToPromptMessageStopped() 133 | if qm.Exception != nil { 134 | log.Println(qm.Exception) 135 | os.Exit(1) 136 | } 137 | continueLoop = false 138 | case "data": 139 | qm := msg.ToPromptMessageData() 140 | // data objects have the fields: Filename, Subfolder, Type 141 | // * Subfolder is the subfolder in the output directory 142 | // * Type is the type of the image temp/ 143 | for k, v := range qm.Data { 144 | if k == "images" || k == "gifs" { 145 | for _, output := range v { 146 | img_data, err := c.GetImage(output) 147 | if err != nil { 148 | log.Println("Failed to get image:", err) 149 | os.Exit(1) 150 | } 151 | f, err := os.Create(output.Filename) 152 | if err != nil { 153 | log.Println("Failed to write image:", err) 154 | os.Exit(1) 155 | } 156 | f.Write(*img_data) 157 | f.Close() 158 | log.Println("Got data: ", output.Filename) 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /graphapi/nodeobjects.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | ) 9 | 10 | type NodeObjects struct { 11 | Objects map[string]*NodeObject 12 | } 13 | 14 | // NodeObject represents the metadata that describes how to generate an instance of a node for a graph. 15 | type NodeObject struct { 16 | Input *NodeObjectInput `json:"input"` 17 | Output *[]interface{} `json:"output"` // output type 18 | OutputIsList *[]bool `json:"output_is_list"` 19 | OutputName *interface{} `json:"output_name"` 20 | Name string `json:"name"` 21 | DisplayName string `json:"display_name"` 22 | Description string `json:"description"` 23 | Category string `json:"category"` 24 | OutputNode bool `json:"output_node"` 25 | InputProperties []*Property `json:"-"` 26 | InputPropertiesByID map[string]*Property `json:"-"` 27 | } 28 | 29 | // GetSettablePropertiesByID returns a map of Properties that are settable. 30 | func (n *NodeObject) GetSettablePropertiesByID() map[string]Property { 31 | retv := make(map[string]Property) 32 | for k, p := range n.InputPropertiesByID { 33 | if (*p).Settable() { 34 | retv[k] = *p 35 | } 36 | } 37 | return retv 38 | } 39 | 40 | // GetSettablePropertiesByID returns a slice of Properties that are settable. 41 | func (n *NodeObject) GetSettableProperties() []Property { 42 | retv := make([]Property, 0) 43 | for _, p := range n.InputProperties { 44 | if (*p).Settable() { 45 | retv = append(retv, *p) 46 | } 47 | } 48 | return retv 49 | } 50 | 51 | type NodeObjectInput struct { 52 | Required map[string]*interface{} `json:"required"` 53 | Optional map[string]*interface{} `json:"optional,omitempty"` 54 | OrderedRequired []string `json:"-"` 55 | OrderedOptional []string `json:"-"` 56 | } 57 | 58 | // NodeObjectInput custom UnmarshalJSON deserializtion maintains the order of the properties in the JSON 59 | func (noi *NodeObjectInput) UnmarshalJSON(b []byte) error { 60 | dec := json.NewDecoder(strings.NewReader(string(b))) 61 | dec.UseNumber() 62 | 63 | if _, err := dec.Token(); err != nil { 64 | return err 65 | } // consume opening brace 66 | 67 | for dec.More() { 68 | t, err := dec.Token() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | key := t.(string) 74 | switch key { 75 | case "required", "optional": 76 | if _, err := dec.Token(); err != nil { // consume opening brace of nested object 77 | return err 78 | } 79 | 80 | currentMap := make(map[string]*interface{}) 81 | currentOrder := make([]string, 0) 82 | for dec.More() { 83 | entryKeyToken, err := dec.Token() 84 | if err != nil { 85 | return err 86 | } 87 | 88 | entryKey := entryKeyToken.(string) 89 | currentOrder = append(currentOrder, entryKey) 90 | 91 | rawValue := &json.RawMessage{} 92 | if err := dec.Decode(rawValue); err != nil { 93 | return err 94 | } 95 | 96 | var i interface{} 97 | if err := json.Unmarshal(*rawValue, &i); err != nil { 98 | return err 99 | } 100 | 101 | currentMap[entryKey] = &i 102 | } 103 | 104 | if _, err := dec.Token(); err != nil { // consume closing brace of nested object 105 | return err 106 | } 107 | 108 | if key == "required" { 109 | noi.Required = currentMap 110 | noi.OrderedRequired = currentOrder 111 | } else if key == "optional" { 112 | noi.Optional = currentMap 113 | noi.OrderedOptional = currentOrder 114 | } 115 | default: 116 | // consume and ignore non-expected field (typically 'hidden' fields) 117 | if err := dec.Decode(new(interface{})); err != nil { 118 | return err 119 | } 120 | } 121 | } 122 | 123 | if _, err := dec.Token(); err != nil { // consume closing brace 124 | return err 125 | } 126 | 127 | return nil 128 | } 129 | 130 | var control_after_generate_text string = ` 131 | [ 132 | [ 133 | "fixed", 134 | "increment", 135 | "decrement", 136 | "randomize" 137 | ] 138 | ] 139 | ` 140 | 141 | func (n *NodeObjects) PopulateInputProperties() { 142 | var cdata []interface{} 143 | json.Unmarshal([]byte(control_after_generate_text), &cdata) 144 | var car interface{} = cdata 145 | 146 | for _, o := range n.Objects { 147 | o.InputPropertiesByID = make(map[string]*Property) 148 | o.InputProperties = make([]*Property, 0) 149 | index := int(0) 150 | 151 | for _, k := range o.Input.OrderedRequired { 152 | p := o.Input.Required[k] 153 | nprop := NewPropertyFromInput(k, false, p, index) 154 | index++ 155 | if nprop != nil { 156 | o.InputProperties = append(o.InputProperties, nprop) 157 | o.InputPropertiesByID[k] = nprop 158 | } else { 159 | slog.Error(fmt.Sprintf("Cannot create property %s for object %s", k, o.Name)) 160 | continue 161 | } 162 | 163 | // handle seed and noise_seed int controls 164 | if ((*nprop).Name() == "seed" || (*nprop).Name() == "noise_seed") && (*nprop).TypeString() == "INT" { 165 | ns_prop := NewPropertyFromInput("control_after_generate", (*nprop).Optional(), &car, index) 166 | index++ 167 | (*ns_prop).SetSerializable(false) 168 | o.InputProperties = append(o.InputProperties, ns_prop) 169 | o.InputPropertiesByID["control_after_generate"] = ns_prop 170 | } 171 | } 172 | 173 | if o.Input.Optional != nil { 174 | for _, k := range o.Input.OrderedOptional { 175 | p := o.Input.Optional[k] 176 | nprop := NewPropertyFromInput(k, true, p, index) 177 | index++ 178 | if nprop != nil { 179 | o.InputProperties = append(o.InputProperties, nprop) 180 | o.InputPropertiesByID[k] = nprop 181 | } else { 182 | slog.Error(fmt.Sprintf("Cannot create property %s for object %s", k, o.Name)) 183 | continue 184 | } 185 | 186 | // handle seed and noise_seed int controls 187 | if (*nprop).Name() == "seed" || (*nprop).Name() == "noise_seed" && (*nprop).TypeString() == "INT" { 188 | ns_prop := NewPropertyFromInput("control_after_generate", (*nprop).Optional(), &car, index) 189 | index++ 190 | o.InputProperties = append(o.InputProperties, ns_prop) 191 | o.InputPropertiesByID["control_after_generate"] = ns_prop 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | func (n *NodeObjects) GetNodeObjectByName(name string) *NodeObject { 199 | val, ok := n.Objects[name] 200 | if ok { 201 | return val 202 | } 203 | return nil 204 | } 205 | -------------------------------------------------------------------------------- /graphapi/node.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "log/slog" 5 | ) 6 | 7 | // GraphNode represents the encapsulation of an individual functionality within a Graph 8 | type GraphNode struct { 9 | ID int `json:"id"` 10 | Type string `json:"type"` 11 | Position interface{} `json:"pos"` 12 | Size Size `json:"size"` 13 | Flags *interface{} `json:"flags"` 14 | Order int `json:"order"` 15 | Mode int `json:"mode"` 16 | Title string `json:"title"` 17 | InternalProperties *map[string]interface{} `json:"properties"` // node properties, not value properties! 18 | // widgets_values can be an array of values, or a map of values 19 | // maps of values can represent cascading style properties in which the setting 20 | // of one property makes certain other properties available 21 | WidgetValues interface{} `json:"widgets_values"` 22 | Color string `json:"color"` 23 | BGColor string `json:"bgcolor"` 24 | Inputs []Slot `json:"inputs,omitempty"` 25 | Outputs []Slot `json:"outputs,omitempty"` 26 | Graph *Graph `json:"-"` 27 | CustomData *interface{} `json:"-"` 28 | Widgets []*Widget `json:"-"` 29 | Properties map[string]Property `json:"-"` 30 | DisplayName string `json:"-"` 31 | Description string `json:"-"` 32 | IsOutput bool `json:"-"` 33 | } 34 | 35 | func (n *GraphNode) WidgetValuesArray() []interface{} { 36 | if n.WidgetValues == nil { 37 | return nil 38 | } 39 | retv, ok := n.WidgetValues.([]interface{}) 40 | if ok { 41 | return retv 42 | } 43 | return nil 44 | } 45 | 46 | func (n *GraphNode) WidgetValuesMap() map[string]interface{} { 47 | if n.WidgetValues == nil { 48 | return nil 49 | } 50 | retv, ok := n.WidgetValues.(map[string]interface{}) 51 | if ok { 52 | return retv 53 | } 54 | return nil 55 | } 56 | 57 | func (n *GraphNode) IsWidgetValueArray() bool { 58 | return n.WidgetValuesArray() != nil 59 | } 60 | 61 | func (n *GraphNode) IsWidgetValueMap() bool { 62 | return n.WidgetValuesMap() != nil 63 | } 64 | 65 | func (n *GraphNode) WidgetValueCount() int { 66 | if n.IsWidgetValueArray() { 67 | return len(n.WidgetValuesArray()) 68 | } 69 | if n.IsWidgetValueMap() { 70 | return len(n.WidgetValuesMap()) 71 | } 72 | return 0 73 | } 74 | 75 | func (n *GraphNode) IsVirtual() bool { 76 | // current nodes that are 'virtual': 77 | switch n.Type { 78 | case "PrimitiveNode": 79 | return true 80 | case "Reroute": 81 | return true 82 | case "Note": 83 | return true 84 | } 85 | return false 86 | } 87 | 88 | // GetLinks returns a slice of Link Ids 89 | func (n *GraphNode) GetLinks() []int { 90 | retv := make([]int, 0) 91 | for _, l := range *n.Outputs[0].Links { 92 | linkInfo := n.Graph.LinksByID[l] 93 | tn := n.Graph.GetNodeById(linkInfo.TargetID) 94 | if tn.Type == "Rerout" { 95 | retv = append(retv, tn.GetLinks()...) 96 | } else { 97 | retv = append(retv, l) 98 | } 99 | } 100 | return retv 101 | } 102 | 103 | func (n *GraphNode) GetPropertyWithName(name string) Property { 104 | retv, ok := n.Properties[name] 105 | if ok { 106 | return retv 107 | } 108 | 109 | // check n.Properties for an aliased property 110 | for _, p := range n.Properties { 111 | if p.GetAlias() == name { 112 | return p 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | // GetPropertesByIndex returns a slice of Properties ordered by thier order in the node desciption 120 | // Because a properties index is for it's index in the node description, and not the index of the property in the node's properties, 121 | // non-indexed properties will be nil in the returned slice 122 | func (n *GraphNode) GetPropertiesByIndex() []Property { 123 | // Initialize with zero length but with an initial capacity. 124 | temp := make([]Property, 0, len(n.Properties)) 125 | lastindex := -1 126 | for _, v := range n.Properties { 127 | index := v.Index() 128 | if index >= 0 { 129 | lastindex = index 130 | } else { 131 | // Handle non-indexed properties, like IMAGEUPLOAD. 132 | if v.TypeString() != "IMAGEUPLOAD" { 133 | slog.Warn("Property with unknown target index of type", "type", v.TypeString()) 134 | } 135 | lastindex++ 136 | } 137 | 138 | // Ensure the slice is large enough by checking if we need to resize. 139 | for len(temp) <= lastindex { 140 | temp = append(temp, nil) // Append nil for interface type to increase slice size. 141 | } 142 | 143 | temp[lastindex] = v 144 | } 145 | 146 | // compact the slice to remove nils 147 | retv := make([]Property, 0, len(temp)) 148 | for _, v := range temp { 149 | if v != nil { 150 | retv = append(retv, v) 151 | } 152 | } 153 | return retv 154 | } 155 | 156 | func (n *GraphNode) GetNodeForInput(slotIndex int) *GraphNode { 157 | if slotIndex >= len(n.Inputs) { 158 | return nil 159 | } 160 | 161 | slot := n.Inputs[slotIndex] 162 | l := n.Graph.GetLinkById(slot.Link) 163 | if l == nil { 164 | return nil 165 | } 166 | return n.Graph.GetNodeById(l.OriginID) 167 | } 168 | 169 | func (n *GraphNode) GetInputLink(slotIndex int) *Link { 170 | ncount := len(n.Inputs) 171 | if ncount == 0 || slotIndex >= ncount { 172 | return nil 173 | } 174 | 175 | slot := n.Inputs[slotIndex] 176 | return n.Graph.GetLinkById(slot.Link) 177 | } 178 | 179 | func (n *GraphNode) GetInputWithName(name string) *Slot { 180 | for i, s := range n.Inputs { 181 | if s.Name == name { 182 | return &n.Inputs[i] 183 | } 184 | } 185 | return nil 186 | } 187 | 188 | func (n *GraphNode) affixPropertyToInputSlot(name string, p Property) { 189 | slot := n.GetInputWithName(name) 190 | if slot != nil { 191 | slot.Property = p 192 | } 193 | } 194 | 195 | func (n *GraphNode) ApplyToGraph() { 196 | // only PrimitiveNode need apply 197 | if n.Type != "PrimitiveNode" { 198 | return 199 | } 200 | 201 | if n.Outputs[0].Links == nil || len(*n.Outputs[0].Links) == 0 { 202 | return 203 | } 204 | 205 | links := n.GetLinks() 206 | // For each output link copy our value over the original widget value 207 | for _, l := range links { 208 | linkinfo := n.Graph.LinksByID[l] 209 | node := n.Graph.GetNodeById(linkinfo.TargetID) 210 | input := node.Inputs[linkinfo.TargetSlot] 211 | widgetName := input.Widget.Name 212 | if widgetName != nil { 213 | // Nodes need a distinct Widget class 214 | // widget.value = this.widgets[0].value; 215 | // fmt.Print() 216 | } 217 | 218 | /* 219 | widgetName := input.Widget. 220 | const widgetName = input.widget.name; 221 | if (widgetName) { 222 | const widget = node.widgets.find((w) => w.name === widgetName); 223 | if (widget) { 224 | widget.value = this.widgets[0].value; 225 | if (widget.callback) { 226 | widget.callback(widget.value, app.canvas, node, app.canvas.graph_mouse, {}); 227 | } 228 | } 229 | } 230 | */ 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /client/wsstatusmessages.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "strconv" 8 | ) 9 | 10 | type WSStatusMessage struct { 11 | Type string `json:"type"` 12 | Data interface{} `json:"Data"` 13 | } 14 | 15 | func (sm *WSStatusMessage) UnmarshalJSON(b []byte) error { 16 | // Unmarshal into an anonymous type equivalent to StatusMessage 17 | // to avoid infinite recursion 18 | var temp struct { 19 | Type string `json:"type"` 20 | Data json.RawMessage `json:"data"` 21 | } 22 | if err := json.Unmarshal(b, &temp); err != nil { 23 | return err 24 | } 25 | 26 | sm.Type = temp.Type 27 | 28 | // Determine the type of Data and unmarshal it accordingly 29 | switch sm.Type { 30 | case "status": 31 | sm.Data = &WSMessageDataStatus{} 32 | case "execution_start": 33 | sm.Data = &WSMessageDataExecutionStart{} 34 | case "execution_cached": 35 | sm.Data = &WSMessageDataExecutionCached{} 36 | case "executing": 37 | sm.Data = &WSMessageDataExecuting{} 38 | case "progress": 39 | sm.Data = &WSMessageDataProgress{} 40 | case "executed": 41 | // this is a special case because the data type is not always the same 42 | // so we need to unmarshal it manually 43 | sm.Data = &WSMessageDataExecuted{} 44 | case "execution_interrupted": 45 | sm.Data = &WSMessageExecutionInterrupted{} 46 | case "execution_error": 47 | sm.Data = &WSMessageExecutionError{} 48 | default: 49 | // Handle unknown data types or return a dedicated error here 50 | sm.Data = nil 51 | } 52 | 53 | if sm.Data != nil { 54 | // Unmarshal the data into the selected type 55 | if err := json.Unmarshal(temp.Data, sm.Data); err != nil { 56 | return err 57 | } 58 | } 59 | 60 | return nil 61 | } 62 | 63 | type WSMessageDataStatus struct { 64 | Status struct { 65 | ExecInfo struct { 66 | QueueRemaining int `json:"queue_remaining"` 67 | } `json:"exec_info"` 68 | } `json:"status"` 69 | } 70 | 71 | /* 72 | {"type": "status", "data": {"status": {"exec_info": {"queue_remaining": 1}}}} 73 | */ 74 | 75 | type WSMessageDataExecutionStart struct { 76 | PromptID string `json:"prompt_id"` 77 | } 78 | 79 | /* 80 | {"type": "execution_start", "data": {"prompt_id": "ed986d60-2a27-4d28-8871-2fdb36582902"}} 81 | */ 82 | 83 | type WSMessageDataExecutionCached struct { 84 | Nodes []interface{} `json:"nodes"` 85 | PromptID string `json:"prompt_id"` 86 | } 87 | 88 | /* 89 | {"type": "execution_cached", "data": {"nodes": [], "prompt_id": "ed986d60-2a27-4d28-8871-2fdb36582902"}} 90 | */ 91 | 92 | type WSMessageDataExecuting struct { 93 | Node *int `json:"node"` 94 | PromptID string `json:"prompt_id"` 95 | } 96 | 97 | func (mde *WSMessageDataExecuting) UnmarshalJSON(b []byte) error { 98 | var temp struct { 99 | Node *string `json:"node"` 100 | PromptID string `json:"prompt_id"` 101 | } 102 | if err := json.Unmarshal(b, &temp); err != nil { 103 | return err 104 | } 105 | 106 | mde.PromptID = temp.PromptID 107 | 108 | // Convert string to int 109 | if temp.Node != nil { 110 | i, err := strconv.Atoi(*temp.Node) 111 | if err != nil { 112 | return err 113 | } 114 | mde.Node = &i 115 | } else { 116 | mde.Node = nil 117 | } 118 | 119 | return nil 120 | } 121 | 122 | /* 123 | {"type": "executing", "data": {"node": "12", "prompt_id": "ed986d60-2a27-4d28-8871-2fdb36582902"}} 124 | */ 125 | 126 | type WSMessageDataProgress struct { 127 | Value int `json:"value"` 128 | Max int `json:"max"` 129 | } 130 | 131 | /* 132 | {"type": "progress", "data": {"value": 1, "max": 20}} 133 | */ 134 | 135 | type WSMessageDataExecuted struct { 136 | Node int `json:"node"` 137 | Output map[string]*[]DataOutput `json:"output"` 138 | PromptID string `json:"prompt_id"` 139 | } 140 | 141 | func (mde *WSMessageDataExecuted) UnmarshalJSON(b []byte) error { 142 | var temp struct { 143 | Node string `json:"node"` 144 | OutputRaw map[string]interface{} `json:"output"` 145 | PromptID string `json:"prompt_id"` 146 | } 147 | if err := json.Unmarshal(b, &temp); err != nil { 148 | return err 149 | } 150 | 151 | // iterrate over Outputraw and see if it can be cast to a slice of interface{} 152 | mde.Output = make(map[string]*[]DataOutput) 153 | for k, v := range temp.OutputRaw { 154 | if val, ok := v.([]interface{}); ok { 155 | mde.Output[k] = &[]DataOutput{} 156 | for _, i := range val { 157 | if outmap, ok := i.(map[string]interface{}); ok { 158 | // ensure the output map has the required fields 159 | outputentry := DataOutput{} 160 | val, ok := outmap["filename"] 161 | if ok { 162 | outputentry.Filename = val.(string) 163 | } else { 164 | slog.Warn(fmt.Sprintf("WSMessageDataExecuted output entry %v unknown type", i)) 165 | continue 166 | } 167 | 168 | val, ok = outmap["subfolder"] 169 | if ok { 170 | outputentry.Subfolder = val.(string) 171 | } else { 172 | // we can ignore this if it's absent 173 | outputentry.Subfolder = "" 174 | } 175 | 176 | val, ok = outmap["type"] 177 | if ok { 178 | outputentry.Type = val.(string) 179 | } else { 180 | slog.Warn(fmt.Sprintf("WSMessageDataExecuted output entry %v unknown type", i)) 181 | continue 182 | } 183 | 184 | *mde.Output[k] = append(*mde.Output[k], outputentry) 185 | } else if outstring, ok := i.(string); ok { 186 | // handle raw text output 187 | textout := DataOutput{ 188 | Filename: "", 189 | Subfolder: "", 190 | Type: "text", 191 | Text: outstring, 192 | } 193 | *mde.Output[k] = append(*mde.Output[k], textout) 194 | } else { 195 | slog.Warn(fmt.Sprintf("WSMessageDataExecuted output entry %v unknown type", i)) 196 | // create an "unknown" type 197 | // convert i to a string and store it as text 198 | outstring := i.(string) 199 | textout := DataOutput{ 200 | Filename: "", 201 | Subfolder: "", 202 | Type: "unknown", 203 | Text: outstring, 204 | } 205 | *mde.Output[k] = append(*mde.Output[k], textout) 206 | } 207 | } 208 | } 209 | 210 | } 211 | 212 | mde.PromptID = temp.PromptID 213 | 214 | // Convert string to int 215 | i, err := strconv.Atoi(temp.Node) 216 | if err != nil { 217 | return err 218 | } 219 | mde.Node = i 220 | 221 | return nil 222 | } 223 | 224 | /* 225 | {"type": "executed", "data": {"node": "19", "output": {"images": [{"filename": "ComfyUI_00046_.png", "subfolder": "", "type": "output"}]}, "prompt_id": "ed986d60-2a27-4d28-8871-2fdb36582902"}} 226 | 227 | // when there are multiple outputs, each output will receive an "executed" 228 | {"type": "executed", "data": {"node": "53", "output": {"images": [{"filename": "ComfyUI_temp_mynbi_00001_.png", "subfolder": "", "type": "temp"}]}, "prompt_id": "3bcf5bac-19e1-4219-a0eb-50a84e4db2ea"}} 229 | {"type": "executed", "data": {"node": "19", "output": {"images": [{"filename": "ComfyUI_00052_.png", "subfolder": "", "type": "output"}]}, "prompt_id": "3bcf5bac-19e1-4219-a0eb-50a84e4db2ea"}} 230 | */ 231 | 232 | type WSMessageExecutionInterrupted struct { 233 | PromptID string `json:"prompt_id"` 234 | Node string `json:"node_id"` 235 | NodeType string `json:"node_type"` 236 | Executed []string `json:"executed"` 237 | } 238 | 239 | /* 240 | {"type": "execution_interrupted", "data": {"prompt_id": "dc7093d7-980a-4fe6-bf0c-f6fef932c74b", "node_id": "19", "node_type": "SaveImage", "executed": ["5", "17", "10", "11"]}} 241 | */ 242 | 243 | type WSMessageExecutionError struct { 244 | PromptID string `json:"prompt_id"` 245 | Node string `json:"node_id"` 246 | NodeType string `json:"node_type"` 247 | Executed []string `json:"executed"` 248 | ExceptionMessage string `json:"exception_message"` 249 | ExceptionType string `json:"exception_type"` 250 | Traceback []string `json:"traceback"` 251 | CurrentInputs map[string]interface{} `json:"current_inputs"` 252 | CurrentOutputs map[int]interface{} `json:"current_outputs"` 253 | } 254 | -------------------------------------------------------------------------------- /examples/img2img/img2img.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 11, 3 | "last_link_id": 12, 4 | "nodes": [ 5 | { 6 | "id": 7, 7 | "type": "CLIPTextEncode", 8 | "pos": [ 9 | 413, 10 | 389 11 | ], 12 | "size": { 13 | "0": 425.27801513671875, 14 | "1": 180.6060791015625 15 | }, 16 | "flags": {}, 17 | "order": 3, 18 | "mode": 0, 19 | "inputs": [ 20 | { 21 | "name": "clip", 22 | "type": "CLIP", 23 | "link": 5 24 | } 25 | ], 26 | "outputs": [ 27 | { 28 | "name": "CONDITIONING", 29 | "type": "CONDITIONING", 30 | "links": [ 31 | 6 32 | ], 33 | "slot_index": 0 34 | } 35 | ], 36 | "properties": { 37 | "Node name for S&R": "CLIPTextEncode" 38 | }, 39 | "widgets_values": [ 40 | "text, watermark" 41 | ] 42 | }, 43 | { 44 | "id": 6, 45 | "type": "CLIPTextEncode", 46 | "pos": [ 47 | 415, 48 | 186 49 | ], 50 | "size": { 51 | "0": 422.84503173828125, 52 | "1": 164.31304931640625 53 | }, 54 | "flags": {}, 55 | "order": 2, 56 | "mode": 0, 57 | "inputs": [ 58 | { 59 | "name": "clip", 60 | "type": "CLIP", 61 | "link": 3 62 | } 63 | ], 64 | "outputs": [ 65 | { 66 | "name": "CONDITIONING", 67 | "type": "CONDITIONING", 68 | "links": [ 69 | 4 70 | ], 71 | "slot_index": 0 72 | } 73 | ], 74 | "properties": { 75 | "Node name for S&R": "CLIPTextEncode" 76 | }, 77 | "widgets_values": [ 78 | "silly green hills, crepuscular rays, sunset" 79 | ] 80 | }, 81 | { 82 | "id": 8, 83 | "type": "VAEDecode", 84 | "pos": [ 85 | 1209, 86 | 188 87 | ], 88 | "size": { 89 | "0": 210, 90 | "1": 46 91 | }, 92 | "flags": {}, 93 | "order": 6, 94 | "mode": 0, 95 | "inputs": [ 96 | { 97 | "name": "samples", 98 | "type": "LATENT", 99 | "link": 7 100 | }, 101 | { 102 | "name": "vae", 103 | "type": "VAE", 104 | "link": 8 105 | } 106 | ], 107 | "outputs": [ 108 | { 109 | "name": "IMAGE", 110 | "type": "IMAGE", 111 | "links": [ 112 | 9 113 | ], 114 | "slot_index": 0 115 | } 116 | ], 117 | "properties": { 118 | "Node name for S&R": "VAEDecode" 119 | } 120 | }, 121 | { 122 | "id": 9, 123 | "type": "SaveImage", 124 | "pos": [ 125 | 1451, 126 | 189 127 | ], 128 | "size": { 129 | "0": 210, 130 | "1": 270 131 | }, 132 | "flags": {}, 133 | "order": 7, 134 | "mode": 0, 135 | "inputs": [ 136 | { 137 | "name": "images", 138 | "type": "IMAGE", 139 | "link": 9 140 | } 141 | ], 142 | "properties": {}, 143 | "widgets_values": [ 144 | "ComfyUI" 145 | ] 146 | }, 147 | { 148 | "id": 10, 149 | "type": "LoadImage", 150 | "pos": [ 151 | 663, 152 | 608 153 | ], 154 | "size": { 155 | "0": 315, 156 | "1": 314 157 | }, 158 | "flags": {}, 159 | "order": 0, 160 | "mode": 0, 161 | "outputs": [ 162 | { 163 | "name": "IMAGE", 164 | "type": "IMAGE", 165 | "links": [ 166 | 10 167 | ], 168 | "shape": 3, 169 | "slot_index": 0 170 | }, 171 | { 172 | "name": "MASK", 173 | "type": "MASK", 174 | "shape": 3 175 | } 176 | ], 177 | "properties": { 178 | "Node name for S&R": "LoadImage" 179 | }, 180 | "widgets_values": [ 181 | "mountains.png", 182 | "image" 183 | ] 184 | }, 185 | { 186 | "id": 11, 187 | "type": "VAEEncode", 188 | "pos": [ 189 | 1010, 190 | 512 191 | ], 192 | "size": { 193 | "0": 210, 194 | "1": 46 195 | }, 196 | "flags": {}, 197 | "order": 4, 198 | "mode": 0, 199 | "inputs": [ 200 | { 201 | "name": "pixels", 202 | "type": "IMAGE", 203 | "link": 10 204 | }, 205 | { 206 | "name": "vae", 207 | "type": "VAE", 208 | "link": 12 209 | } 210 | ], 211 | "outputs": [ 212 | { 213 | "name": "LATENT", 214 | "type": "LATENT", 215 | "links": [ 216 | 11 217 | ], 218 | "shape": 3, 219 | "slot_index": 0 220 | } 221 | ], 222 | "properties": { 223 | "Node name for S&R": "VAEEncode" 224 | } 225 | }, 226 | { 227 | "id": 4, 228 | "type": "CheckpointLoaderSimple", 229 | "pos": [ 230 | 26, 231 | 474 232 | ], 233 | "size": { 234 | "0": 315, 235 | "1": 98 236 | }, 237 | "flags": {}, 238 | "order": 1, 239 | "mode": 0, 240 | "outputs": [ 241 | { 242 | "name": "MODEL", 243 | "type": "MODEL", 244 | "links": [ 245 | 1 246 | ], 247 | "slot_index": 0 248 | }, 249 | { 250 | "name": "CLIP", 251 | "type": "CLIP", 252 | "links": [ 253 | 3, 254 | 5 255 | ], 256 | "slot_index": 1 257 | }, 258 | { 259 | "name": "VAE", 260 | "type": "VAE", 261 | "links": [ 262 | 8, 263 | 12 264 | ], 265 | "slot_index": 2 266 | } 267 | ], 268 | "properties": { 269 | "Node name for S&R": "CheckpointLoaderSimple" 270 | }, 271 | "widgets_values": [ 272 | "v1-5-pruned-emaonly.ckpt" 273 | ] 274 | }, 275 | { 276 | "id": 3, 277 | "type": "KSampler", 278 | "pos": [ 279 | 863, 280 | 186 281 | ], 282 | "size": { 283 | "0": 315, 284 | "1": 262 285 | }, 286 | "flags": {}, 287 | "order": 5, 288 | "mode": 0, 289 | "inputs": [ 290 | { 291 | "name": "model", 292 | "type": "MODEL", 293 | "link": 1 294 | }, 295 | { 296 | "name": "positive", 297 | "type": "CONDITIONING", 298 | "link": 4 299 | }, 300 | { 301 | "name": "negative", 302 | "type": "CONDITIONING", 303 | "link": 6 304 | }, 305 | { 306 | "name": "latent_image", 307 | "type": "LATENT", 308 | "link": 11 309 | } 310 | ], 311 | "outputs": [ 312 | { 313 | "name": "LATENT", 314 | "type": "LATENT", 315 | "links": [ 316 | 7 317 | ], 318 | "slot_index": 0 319 | } 320 | ], 321 | "properties": { 322 | "Node name for S&R": "KSampler" 323 | }, 324 | "widgets_values": [ 325 | 576535993091441, 326 | "randomize", 327 | 20, 328 | 8, 329 | "euler", 330 | "normal", 331 | 0.9145703124999995 332 | ] 333 | } 334 | ], 335 | "links": [ 336 | [ 337 | 1, 338 | 4, 339 | 0, 340 | 3, 341 | 0, 342 | "MODEL" 343 | ], 344 | [ 345 | 3, 346 | 4, 347 | 1, 348 | 6, 349 | 0, 350 | "CLIP" 351 | ], 352 | [ 353 | 4, 354 | 6, 355 | 0, 356 | 3, 357 | 1, 358 | "CONDITIONING" 359 | ], 360 | [ 361 | 5, 362 | 4, 363 | 1, 364 | 7, 365 | 0, 366 | "CLIP" 367 | ], 368 | [ 369 | 6, 370 | 7, 371 | 0, 372 | 3, 373 | 2, 374 | "CONDITIONING" 375 | ], 376 | [ 377 | 7, 378 | 3, 379 | 0, 380 | 8, 381 | 0, 382 | "LATENT" 383 | ], 384 | [ 385 | 8, 386 | 4, 387 | 2, 388 | 8, 389 | 1, 390 | "VAE" 391 | ], 392 | [ 393 | 9, 394 | 8, 395 | 0, 396 | 9, 397 | 0, 398 | "IMAGE" 399 | ], 400 | [ 401 | 10, 402 | 10, 403 | 0, 404 | 11, 405 | 0, 406 | "IMAGE" 407 | ], 408 | [ 409 | 11, 410 | 11, 411 | 0, 412 | 3, 413 | 3, 414 | "LATENT" 415 | ], 416 | [ 417 | 12, 418 | 4, 419 | 2, 420 | 11, 421 | 1, 422 | "VAE" 423 | ] 424 | ], 425 | "groups": [], 426 | "config": {}, 427 | "extra": {}, 428 | "version": 0.4 429 | } -------------------------------------------------------------------------------- /client/comfyclientrequests.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/url" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/richinsley/comfy2go/graphapi" 15 | ) 16 | 17 | /* 18 | @routes.get("/embeddings") 19 | @routes.get("/extensions") 20 | @routes.get("/view") 21 | @routes.get("/view_metadata/{folder_name}") 22 | @routes.get("/system_stats") 23 | @routes.get("/prompt") 24 | @routes.get("/object_info") 25 | @routes.get("/object_info/{node_class}") 26 | @routes.get("/history") 27 | @routes.get("/history/{prompt_id}") 28 | @routes.get("/queue") 29 | 30 | @routes.post("/prompt") 31 | @routes.post("/queue") 32 | @routes.post("/interrupt") 33 | @routes.post("/history") 34 | @routes.post("/upload/image") 35 | @routes.post("/upload/mask") 36 | */ 37 | 38 | func (c *ComfyClient) GetSystemStats() (*SystemStats, error) { 39 | err := c.CheckConnection() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/system_stats", c.serverBaseAddress)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | body, _ := io.ReadAll(resp.Body) 50 | retv := &SystemStats{} 51 | err = json.Unmarshal(body, &retv) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return retv, nil 57 | } 58 | 59 | func (c *ComfyClient) GetPromptHistoryByIndex() ([]PromptHistoryItem, error) { 60 | history, err := c.GetPromptHistoryByID() 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | retv := make([]PromptHistoryItem, len(history)) 66 | index := 0 67 | // ComfyUI does not recalculate the indicies of prompt history items, 68 | // so the indecies may not always be ordered 0..n 69 | // We'll create a slice out of the map items, and then sort them 70 | for _, h := range history { 71 | retv[index] = h 72 | index++ 73 | } 74 | 75 | sort.Slice(retv, func(i, j int) bool { 76 | return retv[i].Index < retv[j].Index 77 | }) 78 | 79 | return retv, nil 80 | } 81 | 82 | func (c *ComfyClient) GetPromptHistoryByID() (map[string]PromptHistoryItem, error) { 83 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/history", c.serverBaseAddress)) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | // we need to re-arrange the data into something more coherent 89 | // We're going to have to make an adapter that reconstructs an actual prompt 90 | // from the mangled data 91 | type internalOutputs struct { 92 | Images *[]DataOutput `json:"images"` 93 | } 94 | type internalPromptHistoryItem struct { 95 | // The prompt is stored as an array layed out like this: 96 | // [ 97 | // [0] index int, 98 | // [1] promptID string, 99 | // [2] prompt map[string]graphapi.PromptNode, // we'll ignore this 100 | // [3] extra_data graphapi.PromptExtraData, // the graph is in here 101 | // [4] outputs []string // array of nodeIDs that have outputs 102 | // ] 103 | Prompt []interface{} `json:"prompt"` 104 | Outputs map[string]internalOutputs `json:"outputs"` 105 | } 106 | 107 | // read in the body, and deserialize to our temp internalPromptHistoryItem type 108 | body, _ := io.ReadAll(resp.Body) 109 | history := make(map[string]internalPromptHistoryItem) 110 | err = json.Unmarshal(body, &history) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | // try to reconstruct the data into PromptHistoryItem 116 | ret := make(map[string]PromptHistoryItem) 117 | for k, ph := range history { 118 | index := int(ph.Prompt[0].(float64)) 119 | 120 | // extract the graph from ph.Prompt[3]["extra_pnginfo"]["workflow"] 121 | extra_data, _ := ph.Prompt[3].(map[string]interface{}) 122 | extra_pnginfo, _ := extra_data["extra_pnginfo"].(map[string]interface{}) 123 | workflow := extra_pnginfo["workflow"] 124 | // workflow is now an interface{} 125 | // serialize it back and re-deserialize as a graph 126 | // this could be more efficient with raw json, but ugh! 127 | gdata, _ := json.Marshal(workflow) 128 | graph := &graphapi.Graph{} 129 | err = json.Unmarshal(gdata, &graph) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | // reconstruct 135 | item := &PromptHistoryItem{ 136 | PromptID: k, 137 | Index: index, 138 | Graph: graph, 139 | Outputs: make(map[int][]DataOutput), 140 | } 141 | 142 | // rebuild the images output map 143 | for k, o := range ph.Outputs { 144 | oid, _ := strconv.Atoi(k) 145 | item.Outputs[oid] = *o.Images 146 | } 147 | ret[k] = *item 148 | } 149 | return ret, nil 150 | } 151 | 152 | // GetViewMetadata retrieves the '__metadata__' field in a safetensors file. 153 | // checkpoints 154 | // vae 155 | // loras 156 | // clip 157 | // unet 158 | // controlnet 159 | // style_models 160 | // clip_vision 161 | // gligen 162 | // configs 163 | // hypernetworks 164 | // upscale_models 165 | // onnx 166 | // fonts 167 | func (c *ComfyClient) GetViewMetadata(folder string, file string) (string, error) { 168 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/view_metadata/%s?filename=%s", c.serverBaseAddress, folder, file)) 169 | if err != nil { 170 | return "", err 171 | } 172 | 173 | body, _ := io.ReadAll(resp.Body) 174 | return string(body), nil 175 | } 176 | 177 | // GetImage 178 | func (c *ComfyClient) GetImage(image_data DataOutput) (*[]byte, error) { 179 | params := url.Values{} 180 | params.Add("filename", image_data.Filename) 181 | params.Add("subfolder", image_data.Subfolder) 182 | params.Add("type", image_data.Type) 183 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/view?%s", c.serverBaseAddress, params.Encode())) 184 | 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | body, _ := io.ReadAll(resp.Body) 190 | return &body, nil 191 | } 192 | 193 | // GetEmbeddings retrieves the list of Embeddings models installed on the ComfyUI server. 194 | func (c *ComfyClient) GetEmbeddings() ([]string, error) { 195 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/embeddings", c.serverBaseAddress)) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | body, _ := io.ReadAll(resp.Body) 201 | retv := make([]string, 0) 202 | err = json.Unmarshal(body, &retv) 203 | if err != nil { 204 | return nil, err 205 | } 206 | 207 | return retv, nil 208 | } 209 | 210 | func (c *ComfyClient) GetQueueExecutionInfo() (*QueueExecInfo, error) { 211 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/prompt", c.serverBaseAddress)) 212 | if err != nil { 213 | return nil, err 214 | } 215 | 216 | body, _ := io.ReadAll(resp.Body) 217 | queue_exec := &QueueExecInfo{} 218 | err = json.Unmarshal(body, &queue_exec) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | return queue_exec, nil 224 | } 225 | 226 | // GetExtensions retrieves the list of extensions installed on the ComfyUI server. 227 | func (c *ComfyClient) GetExtensions() ([]string, error) { 228 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/extensions", c.serverBaseAddress)) 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | body, _ := io.ReadAll(resp.Body) 234 | retv := make([]string, 0) 235 | err = json.Unmarshal(body, &retv) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | return retv, nil 241 | } 242 | 243 | func (c *ComfyClient) GetObjectInfos() (*graphapi.NodeObjects, error) { 244 | resp, err := c.httpclient.Get(fmt.Sprintf("http://%s/object_info", c.serverBaseAddress)) 245 | 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | body, _ := io.ReadAll(resp.Body) 251 | result := &graphapi.NodeObjects{} 252 | err = json.Unmarshal(body, &result.Objects) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | result.PopulateInputProperties() 258 | return result, nil 259 | } 260 | 261 | func (c *ComfyClient) QueuePrompt(graph *graphapi.Graph) (*QueueItem, error) { 262 | err := c.CheckConnection() 263 | if err != nil { 264 | return nil, err 265 | } 266 | 267 | prompt, err := graph.GraphToPrompt(c.clientid) 268 | if err != nil { 269 | return nil, err 270 | } 271 | 272 | // prevent a race where the ws may provide messages about a queued item before 273 | // we add the item to our internal map 274 | c.webSocket.LockRead() 275 | defer c.webSocket.UnlockRead() 276 | 277 | data, _ := json.Marshal(prompt) 278 | resp, err := c.httpclient.Post(fmt.Sprintf("http://%s/prompt", c.serverBaseAddress), "application/json", strings.NewReader(string(data))) 279 | 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | body, _ := io.ReadAll(resp.Body) 285 | 286 | // create the queue item 287 | item := &QueueItem{ 288 | Workflow: graph, 289 | Messages: make(chan PromptMessage), 290 | } 291 | 292 | err = json.Unmarshal(body, &item) 293 | if err != nil { 294 | // mmm-k, is it one of these: 295 | // {"error": {"type": "prompt_no_outputs", 296 | // "message": "Prompt has no outputs", 297 | // "details": "", 298 | // "extra_info": {} 299 | // }, 300 | // "node_errors": [] 301 | // } 302 | perror := &PromptErrorMessage{} 303 | perr := json.Unmarshal(body, &perror) 304 | if perr != nil { 305 | // return the original error 306 | slog.Error("error unmarshalling prompt error", "body", string(body)) 307 | return nil, err 308 | } else { 309 | return nil, errors.New(perror.Error.Message) 310 | } 311 | } 312 | c.queueditems[item.PromptID] = item 313 | return item, nil 314 | } 315 | 316 | func (c *ComfyClient) Interrupt() error { 317 | resp, err := c.httpclient.Post(fmt.Sprintf("http://%s/interrupt", c.serverBaseAddress), "application/json", strings.NewReader("{}")) 318 | if err != nil { 319 | return err 320 | } 321 | 322 | io.ReadAll(resp.Body) 323 | return nil 324 | } 325 | 326 | func (c *ComfyClient) EraseHistory() error { 327 | // delete post takes an array of IDs. We'll provide a single ID in a json array 328 | data := "{\"clear\": \"clear\"}" 329 | resp, err := c.httpclient.Post(fmt.Sprintf("http://%s/history", c.serverBaseAddress), "application/json", strings.NewReader(data)) 330 | if err != nil { 331 | return err 332 | } 333 | 334 | io.ReadAll(resp.Body) 335 | return nil 336 | } 337 | 338 | func (c *ComfyClient) EraseHistoryItem(promptID string) error { 339 | // delete post takes an array of IDs. We'll provide a single ID in a json array 340 | item := fmt.Sprintf("{\"delete\": [\"%s\"]}", promptID) 341 | resp, err := c.httpclient.Post(fmt.Sprintf("http://%s/history", c.serverBaseAddress), "application/json", strings.NewReader(item)) 342 | if err != nil { 343 | return err 344 | } 345 | 346 | io.ReadAll(resp.Body) 347 | return nil 348 | } 349 | -------------------------------------------------------------------------------- /examples/simple_api/simple_api.json: -------------------------------------------------------------------------------- 1 | { 2 | "last_node_id": 15, 3 | "last_link_id": 15, 4 | "nodes": [ 5 | { 6 | "id": 8, 7 | "type": "VAEDecode", 8 | "pos": [ 9 | 1209, 10 | 188 11 | ], 12 | "size": { 13 | "0": 210, 14 | "1": 46 15 | }, 16 | "flags": {}, 17 | "order": 10, 18 | "mode": 0, 19 | "inputs": [ 20 | { 21 | "name": "samples", 22 | "type": "LATENT", 23 | "link": 7 24 | }, 25 | { 26 | "name": "vae", 27 | "type": "VAE", 28 | "link": 8 29 | } 30 | ], 31 | "outputs": [ 32 | { 33 | "name": "IMAGE", 34 | "type": "IMAGE", 35 | "links": [ 36 | 9 37 | ], 38 | "slot_index": 0 39 | } 40 | ], 41 | "properties": { 42 | "Node name for S&R": "VAEDecode" 43 | } 44 | }, 45 | { 46 | "id": 9, 47 | "type": "SaveImage", 48 | "pos": [ 49 | 1451, 50 | 189 51 | ], 52 | "size": { 53 | "0": 210, 54 | "1": 270 55 | }, 56 | "flags": {}, 57 | "order": 11, 58 | "mode": 0, 59 | "inputs": [ 60 | { 61 | "name": "images", 62 | "type": "IMAGE", 63 | "link": 9 64 | } 65 | ], 66 | "properties": {}, 67 | "widgets_values": [ 68 | "ComfyUI" 69 | ] 70 | }, 71 | { 72 | "id": 7, 73 | "type": "CLIPTextEncode", 74 | "pos": [ 75 | 413, 76 | 389 77 | ], 78 | "size": { 79 | "0": 425.27801513671875, 80 | "1": 180.6060791015625 81 | }, 82 | "flags": {}, 83 | "order": 8, 84 | "mode": 0, 85 | "inputs": [ 86 | { 87 | "name": "clip", 88 | "type": "CLIP", 89 | "link": 5 90 | }, 91 | { 92 | "name": "text", 93 | "type": "STRING", 94 | "link": 11, 95 | "widget": { 96 | "name": "text", 97 | "config": [ 98 | "STRING", 99 | { 100 | "multiline": true 101 | } 102 | ] 103 | }, 104 | "slot_index": 1 105 | } 106 | ], 107 | "outputs": [ 108 | { 109 | "name": "CONDITIONING", 110 | "type": "CONDITIONING", 111 | "links": [ 112 | 6 113 | ], 114 | "slot_index": 0 115 | } 116 | ], 117 | "properties": { 118 | "Node name for S&R": "CLIPTextEncode" 119 | }, 120 | "widgets_values": [ 121 | "text, watermark" 122 | ] 123 | }, 124 | { 125 | "id": 6, 126 | "type": "CLIPTextEncode", 127 | "pos": [ 128 | 415, 129 | 186 130 | ], 131 | "size": { 132 | "0": 422.84503173828125, 133 | "1": 164.31304931640625 134 | }, 135 | "flags": {}, 136 | "order": 7, 137 | "mode": 0, 138 | "inputs": [ 139 | { 140 | "name": "clip", 141 | "type": "CLIP", 142 | "link": 3 143 | }, 144 | { 145 | "name": "text", 146 | "type": "STRING", 147 | "link": 12, 148 | "widget": { 149 | "name": "text", 150 | "config": [ 151 | "STRING", 152 | { 153 | "multiline": true 154 | } 155 | ] 156 | }, 157 | "slot_index": 1 158 | } 159 | ], 160 | "outputs": [ 161 | { 162 | "name": "CONDITIONING", 163 | "type": "CONDITIONING", 164 | "links": [ 165 | 4 166 | ], 167 | "slot_index": 0 168 | } 169 | ], 170 | "properties": { 171 | "Node name for S&R": "CLIPTextEncode" 172 | }, 173 | "widgets_values": [ 174 | "beautiful scenery nature glass bottle landscape, , purple galaxy bottle," 175 | ] 176 | }, 177 | { 178 | "id": 5, 179 | "type": "EmptyLatentImage", 180 | "pos": [ 181 | 413, 182 | 619 183 | ], 184 | "size": { 185 | "0": 315, 186 | "1": 106 187 | }, 188 | "flags": {}, 189 | "order": 6, 190 | "mode": 0, 191 | "inputs": [ 192 | { 193 | "name": "width", 194 | "type": "INT", 195 | "link": 13, 196 | "widget": { 197 | "name": "width", 198 | "config": [ 199 | "INT", 200 | { 201 | "default": 512, 202 | "min": 64, 203 | "max": 8192, 204 | "step": 8 205 | } 206 | ] 207 | }, 208 | "slot_index": 0 209 | }, 210 | { 211 | "name": "height", 212 | "type": "INT", 213 | "link": 14, 214 | "widget": { 215 | "name": "height", 216 | "config": [ 217 | "INT", 218 | { 219 | "default": 512, 220 | "min": 64, 221 | "max": 8192, 222 | "step": 8 223 | } 224 | ] 225 | }, 226 | "slot_index": 1 227 | } 228 | ], 229 | "outputs": [ 230 | { 231 | "name": "LATENT", 232 | "type": "LATENT", 233 | "links": [ 234 | 2 235 | ], 236 | "slot_index": 0 237 | } 238 | ], 239 | "properties": { 240 | "Node name for S&R": "EmptyLatentImage" 241 | }, 242 | "widgets_values": [ 243 | 512, 244 | 512, 245 | 1 246 | ] 247 | }, 248 | { 249 | "id": 11, 250 | "type": "Text box", 251 | "pos": [ 252 | -553, 253 | 326 254 | ], 255 | "size": { 256 | "0": 400, 257 | "1": 200 258 | }, 259 | "flags": {}, 260 | "order": 0, 261 | "mode": 0, 262 | "outputs": [ 263 | { 264 | "name": "STRING", 265 | "type": "STRING", 266 | "links": [ 267 | 11 268 | ], 269 | "shape": 3 270 | } 271 | ], 272 | "title": "Negative", 273 | "properties": { 274 | "Node name for S&R": "Text box" 275 | }, 276 | "widgets_values": [ 277 | "" 278 | ] 279 | }, 280 | { 281 | "id": 12, 282 | "type": "Text box", 283 | "pos": [ 284 | -553, 285 | 88 286 | ], 287 | "size": { 288 | "0": 400, 289 | "1": 200 290 | }, 291 | "flags": {}, 292 | "order": 1, 293 | "mode": 0, 294 | "outputs": [ 295 | { 296 | "name": "STRING", 297 | "type": "STRING", 298 | "links": [ 299 | 12 300 | ], 301 | "shape": 3 302 | } 303 | ], 304 | "title": "Positive", 305 | "properties": { 306 | "Node name for S&R": "Text box" 307 | }, 308 | "widgets_values": [ 309 | "beautiful scenery nature glass bottle landscape, , purple galaxy bottle," 310 | ] 311 | }, 312 | { 313 | "id": 13, 314 | "type": "Integer", 315 | "pos": [ 316 | -544, 317 | 573 318 | ], 319 | "size": { 320 | "0": 315, 321 | "1": 58 322 | }, 323 | "flags": {}, 324 | "order": 2, 325 | "mode": 0, 326 | "outputs": [ 327 | { 328 | "name": "INT", 329 | "type": "INT", 330 | "links": [ 331 | 13 332 | ], 333 | "shape": 3 334 | } 335 | ], 336 | "title": "Width", 337 | "properties": { 338 | "Node name for S&R": "Integer" 339 | }, 340 | "widgets_values": [ 341 | 512 342 | ] 343 | }, 344 | { 345 | "id": 14, 346 | "type": "Integer", 347 | "pos": [ 348 | -542, 349 | 677 350 | ], 351 | "size": { 352 | "0": 315, 353 | "1": 58 354 | }, 355 | "flags": {}, 356 | "order": 3, 357 | "mode": 0, 358 | "outputs": [ 359 | { 360 | "name": "INT", 361 | "type": "INT", 362 | "links": [ 363 | 14 364 | ], 365 | "shape": 3 366 | } 367 | ], 368 | "title": "Height", 369 | "properties": { 370 | "Node name for S&R": "Integer" 371 | }, 372 | "widgets_values": [ 373 | 512 374 | ] 375 | }, 376 | { 377 | "id": 4, 378 | "type": "CheckpointLoaderSimple", 379 | "pos": [ 380 | 22, 381 | 48 382 | ], 383 | "size": { 384 | "0": 315, 385 | "1": 98 386 | }, 387 | "flags": {}, 388 | "order": 4, 389 | "mode": 0, 390 | "outputs": [ 391 | { 392 | "name": "MODEL", 393 | "type": "MODEL", 394 | "links": [ 395 | 1 396 | ], 397 | "slot_index": 0 398 | }, 399 | { 400 | "name": "CLIP", 401 | "type": "CLIP", 402 | "links": [ 403 | 3, 404 | 5 405 | ], 406 | "slot_index": 1 407 | }, 408 | { 409 | "name": "VAE", 410 | "type": "VAE", 411 | "links": [ 412 | 8 413 | ], 414 | "slot_index": 2 415 | } 416 | ], 417 | "properties": { 418 | "Node name for S&R": "CheckpointLoaderSimple" 419 | }, 420 | "widgets_values": [ 421 | "v1-5-pruned-emaonly.ckpt" 422 | ] 423 | }, 424 | { 425 | "id": 3, 426 | "type": "KSampler", 427 | "pos": [ 428 | 863, 429 | 186 430 | ], 431 | "size": [ 432 | 315, 433 | 262 434 | ], 435 | "flags": {}, 436 | "order": 9, 437 | "mode": 0, 438 | "inputs": [ 439 | { 440 | "name": "model", 441 | "type": "MODEL", 442 | "link": 1 443 | }, 444 | { 445 | "name": "positive", 446 | "type": "CONDITIONING", 447 | "link": 4 448 | }, 449 | { 450 | "name": "negative", 451 | "type": "CONDITIONING", 452 | "link": 6 453 | }, 454 | { 455 | "name": "latent_image", 456 | "type": "LATENT", 457 | "link": 2 458 | }, 459 | { 460 | "name": "seed", 461 | "type": "INT", 462 | "link": 15, 463 | "widget": { 464 | "name": "seed", 465 | "config": [ 466 | "INT", 467 | { 468 | "default": 0, 469 | "min": 0, 470 | "max": 18446744073709552000 471 | } 472 | ] 473 | }, 474 | "slot_index": 4 475 | } 476 | ], 477 | "outputs": [ 478 | { 479 | "name": "LATENT", 480 | "type": "LATENT", 481 | "links": [ 482 | 7 483 | ], 484 | "slot_index": 0 485 | } 486 | ], 487 | "properties": { 488 | "Node name for S&R": "KSampler" 489 | }, 490 | "widgets_values": [ 491 | 711533754111174, 492 | "fixed", 493 | 20, 494 | 8, 495 | "euler", 496 | "normal", 497 | 1 498 | ] 499 | }, 500 | { 501 | "id": 15, 502 | "type": "Integer", 503 | "pos": [ 504 | -539, 505 | 792 506 | ], 507 | "size": { 508 | "0": 315, 509 | "1": 58 510 | }, 511 | "flags": {}, 512 | "order": 5, 513 | "mode": 0, 514 | "outputs": [ 515 | { 516 | "name": "INT", 517 | "type": "INT", 518 | "links": [ 519 | 15 520 | ], 521 | "shape": 3 522 | } 523 | ], 524 | "title": "Seed", 525 | "properties": { 526 | "Node name for S&R": "Integer" 527 | }, 528 | "widgets_values": [ 529 | 1 530 | ] 531 | } 532 | ], 533 | "links": [ 534 | [ 535 | 1, 536 | 4, 537 | 0, 538 | 3, 539 | 0, 540 | "MODEL" 541 | ], 542 | [ 543 | 2, 544 | 5, 545 | 0, 546 | 3, 547 | 3, 548 | "LATENT" 549 | ], 550 | [ 551 | 3, 552 | 4, 553 | 1, 554 | 6, 555 | 0, 556 | "CLIP" 557 | ], 558 | [ 559 | 4, 560 | 6, 561 | 0, 562 | 3, 563 | 1, 564 | "CONDITIONING" 565 | ], 566 | [ 567 | 5, 568 | 4, 569 | 1, 570 | 7, 571 | 0, 572 | "CLIP" 573 | ], 574 | [ 575 | 6, 576 | 7, 577 | 0, 578 | 3, 579 | 2, 580 | "CONDITIONING" 581 | ], 582 | [ 583 | 7, 584 | 3, 585 | 0, 586 | 8, 587 | 0, 588 | "LATENT" 589 | ], 590 | [ 591 | 8, 592 | 4, 593 | 2, 594 | 8, 595 | 1, 596 | "VAE" 597 | ], 598 | [ 599 | 9, 600 | 8, 601 | 0, 602 | 9, 603 | 0, 604 | "IMAGE" 605 | ], 606 | [ 607 | 11, 608 | 11, 609 | 0, 610 | 7, 611 | 1, 612 | "STRING" 613 | ], 614 | [ 615 | 12, 616 | 12, 617 | 0, 618 | 6, 619 | 1, 620 | "STRING" 621 | ], 622 | [ 623 | 13, 624 | 13, 625 | 0, 626 | 5, 627 | 0, 628 | "INT" 629 | ], 630 | [ 631 | 14, 632 | 14, 633 | 0, 634 | 5, 635 | 1, 636 | "INT" 637 | ], 638 | [ 639 | 15, 640 | 15, 641 | 0, 642 | 3, 643 | 4, 644 | "INT" 645 | ] 646 | ], 647 | "groups": [ 648 | { 649 | "title": "API", 650 | "bounding": [ 651 | -577, 652 | 17, 653 | 461, 654 | 871 655 | ], 656 | "color": "#3f789e" 657 | } 658 | ], 659 | "config": {}, 660 | "extra": {}, 661 | "version": 0.4 662 | } -------------------------------------------------------------------------------- /client/comfyclient.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/google/uuid" 15 | "github.com/gorilla/websocket" 16 | "github.com/richinsley/comfy2go/graphapi" 17 | ) 18 | 19 | type QueuedItemStoppedReason string 20 | 21 | const ( 22 | QueuedItemStoppedReasonFinished QueuedItemStoppedReason = "finished" 23 | QueuedItemStoppedReasonInterrupted QueuedItemStoppedReason = "interrupted" 24 | QueuedItemStoppedReasonError QueuedItemStoppedReason = "error" 25 | ) 26 | 27 | type ComfyClientCallbacks struct { 28 | ClientQueueCountChanged func(*ComfyClient, int) 29 | QueuedItemStarted func(*ComfyClient, *QueueItem) 30 | QueuedItemStopped func(*ComfyClient, *QueueItem, QueuedItemStoppedReason) 31 | QueuedItemDataAvailable func(*ComfyClient, *QueueItem, *PromptMessageData) 32 | } 33 | 34 | // ComfyClient is the top level object that allows for interaction with the ComfyUI backend 35 | type ComfyClient struct { 36 | serverBaseAddress string 37 | serverAddress string 38 | serverPort int 39 | clientid string 40 | webSocket *WebSocketConnection 41 | nodeobjects *graphapi.NodeObjects 42 | initialized bool 43 | queueditems map[string]*QueueItem 44 | queuecount int 45 | callbacks *ComfyClientCallbacks 46 | lastProcessedPromptID string 47 | timeout int 48 | httpclient *http.Client 49 | } 50 | 51 | // NewComfyClientWithTimeout creates a new instance of a Comfy2go client with a connection timeout 52 | func NewComfyClientWithTimeout(server_address string, server_port int, callbacks *ComfyClientCallbacks, timeout int, retry int) *ComfyClient { 53 | sbaseaddr := server_address + ":" + strconv.Itoa(server_port) 54 | cid := uuid.New().String() 55 | retv := &ComfyClient{ 56 | serverBaseAddress: sbaseaddr, 57 | serverAddress: server_address, 58 | serverPort: server_port, 59 | clientid: cid, 60 | queueditems: make(map[string]*QueueItem), 61 | webSocket: &WebSocketConnection{ 62 | WebSocketURL: "ws://" + sbaseaddr + "/ws?clientId=" + cid, 63 | ConnectionDone: make(chan bool), 64 | MaxRetry: retry, // Maximum number of retries 65 | ManagerStarted: false, 66 | BaseDelay: 1 * time.Second, 67 | MaxDelay: 10 * time.Second, 68 | Dialer: *websocket.DefaultDialer, 69 | }, 70 | initialized: false, 71 | queuecount: 0, 72 | callbacks: callbacks, 73 | timeout: timeout, 74 | httpclient: &http.Client{}, 75 | } 76 | // golang uses mark-sweep GC, so this circular reference should be fine 77 | retv.webSocket.Callback = retv 78 | return retv 79 | } 80 | 81 | // NewComfyClient creates a new instance of a Comfy2go client 82 | func NewComfyClient(server_address string, server_port int, callbacks *ComfyClientCallbacks) *ComfyClient { 83 | sbaseaddr := server_address + ":" + strconv.Itoa(server_port) 84 | cid := uuid.New().String() 85 | retv := &ComfyClient{ 86 | serverBaseAddress: sbaseaddr, 87 | serverAddress: server_address, 88 | serverPort: server_port, 89 | clientid: cid, 90 | queueditems: make(map[string]*QueueItem), 91 | webSocket: &WebSocketConnection{ 92 | WebSocketURL: "ws://" + sbaseaddr + "/ws?clientId=" + cid, 93 | ConnectionDone: make(chan bool), 94 | MaxRetry: 5, // Maximum number of retries 95 | ManagerStarted: false, 96 | BaseDelay: 1 * time.Second, 97 | MaxDelay: 10 * time.Second, 98 | Dialer: *websocket.DefaultDialer, 99 | }, 100 | initialized: false, 101 | queuecount: 0, 102 | callbacks: callbacks, 103 | timeout: -1, 104 | httpclient: &http.Client{}, 105 | } 106 | // golang uses mark-sweep GC, so this circular reference should be fine 107 | retv.webSocket.Callback = retv 108 | return retv 109 | } 110 | 111 | func (cc *ComfyClient) SetDialer(dialer *websocket.Dialer) { 112 | // dereference the pointer and copy the values 113 | cc.webSocket.Dialer = *dialer 114 | } 115 | 116 | func (cc *ComfyClient) OnMessage(message string) { 117 | cc.OnWindowSocketMessage(message) 118 | } 119 | 120 | // IsInitialized returns true if the client's websocket is connected and initialized 121 | func (c *ComfyClient) IsInitialized() bool { 122 | if c.initialized { 123 | // ping the websocket to see if it is still connected 124 | err := c.webSocket.Ping() 125 | if err != nil { 126 | c.webSocket.Conn.Close() 127 | c.initialized = false 128 | c.webSocket.IsConnected = false 129 | } 130 | } 131 | return c.initialized 132 | } 133 | 134 | // CheckConnection checks if the websocket connection is still active and tries to reinitialize if not 135 | func (c *ComfyClient) CheckConnection() error { 136 | if !c.IsInitialized() { 137 | // try to initialize first 138 | err := c.Init() 139 | if err != nil { 140 | return err 141 | } 142 | } 143 | return nil 144 | } 145 | 146 | // Init starts the websocket connection (if not already connected) and retrieves the collection of node objects 147 | func (c *ComfyClient) Init() error { 148 | if !c.webSocket.IsConnected { 149 | // as soon as the ws is connected, it will receive a "status" message of the current status 150 | // of the ComfyUI server 151 | err := c.webSocket.ConnectWithManager(c.timeout) 152 | if err != nil { 153 | return err 154 | } 155 | } 156 | 157 | // Get the object infos for the Comfy Server 158 | object_infos, err := c.GetObjectInfos() 159 | if err != nil { 160 | return err 161 | } 162 | 163 | c.nodeobjects = object_infos 164 | c.initialized = true 165 | return nil 166 | } 167 | 168 | // ClientID returns the unique client ID for the connection to the ComfyUI backend 169 | func (c *ComfyClient) ClientID() string { 170 | return c.clientid 171 | } 172 | 173 | // return the underlying http client 174 | func (c *ComfyClient) HttpClient() *http.Client { 175 | return c.httpclient 176 | } 177 | 178 | // set the underlying http client 179 | func (c *ComfyClient) SetHttpClient(client *http.Client) { 180 | c.httpclient = client 181 | } 182 | 183 | // NewGraphFromJsonReader creates a new graph from the data read from an io.Reader 184 | func (c *ComfyClient) NewGraphFromJsonReader(r io.Reader) (*graphapi.Graph, *[]string, error) { 185 | if !c.IsInitialized() { 186 | // try to initialize first 187 | err := c.Init() 188 | if err != nil { 189 | return nil, nil, err 190 | } 191 | } 192 | return graphapi.NewGraphFromJsonReader(r, c.nodeobjects) 193 | } 194 | 195 | // NewGraphFromJsonFile creates a new graph from a JSON file 196 | func (c *ComfyClient) NewGraphFromJsonFile(path string) (*graphapi.Graph, *[]string, error) { 197 | if !c.IsInitialized() { 198 | // try to initialize first 199 | err := c.Init() 200 | if err != nil { 201 | return nil, nil, err 202 | } 203 | } 204 | return graphapi.NewGraphFromJsonFile(path, c.nodeobjects) 205 | } 206 | 207 | // NewGraphFromJsonString creates a new graph from a JSON string 208 | func (c *ComfyClient) NewGraphFromJsonString(path string) (*graphapi.Graph, *[]string, error) { 209 | if !c.IsInitialized() { 210 | // try to initialize first 211 | err := c.Init() 212 | if err != nil { 213 | return nil, nil, err 214 | } 215 | } 216 | return graphapi.NewGraphFromJsonString(path, c.nodeobjects) 217 | } 218 | 219 | // NewGraphFromPNGReader extracts the workflow from PNG data read from an io.Reader and creates a new graph 220 | func (c *ComfyClient) NewGraphFromPNGReader(r io.Reader) (*graphapi.Graph, *[]string, error) { 221 | metadata, err := GetPngMetadata(r) 222 | if err != nil { 223 | return nil, nil, err 224 | } 225 | 226 | // get the workflow from the PNG metadata 227 | workflow, ok := metadata["workflow"] 228 | if !ok { 229 | return nil, nil, errors.New("png does not contain workflow metadata") 230 | } 231 | reader := strings.NewReader(workflow) 232 | 233 | graph, missing, err := c.NewGraphFromJsonReader(reader) 234 | if err != nil { 235 | return nil, missing, err 236 | } 237 | return graph, missing, nil 238 | } 239 | 240 | // NewGraphFromPNGReader extracts the workflow from PNG data read from a file and creates a new graph 241 | func (c *ComfyClient) NewGraphFromPNGFile(path string) (*graphapi.Graph, *[]string, error) { 242 | file, err := os.Open(path) 243 | if err != nil { 244 | return nil, nil, err 245 | } 246 | defer file.Close() 247 | return c.NewGraphFromPNGReader(file) 248 | } 249 | 250 | // GetQueuedItem returns a QueueItem that was queued with the ComfyClient, that has not been processed yet 251 | // or is currently being processed. Once a QueueItem has been processed, it will not be available with this method. 252 | func (c *ComfyClient) GetQueuedItem(prompt_id string) *QueueItem { 253 | val, ok := c.queueditems[prompt_id] 254 | if ok { 255 | return val 256 | } 257 | return nil 258 | } 259 | 260 | // OnWindowSocketMessage processes each message received from the websocket connection to ComfyUI. 261 | // The messages are parsed, and translated into PromptMessage structs and placed into the correct QueuedItem's message channel. 262 | func (c *ComfyClient) OnWindowSocketMessage(msg string) { 263 | message := &WSStatusMessage{} 264 | err := json.Unmarshal([]byte(msg), &message) 265 | if err != nil { 266 | slog.Error("Deserializing Status Message:", err) 267 | } 268 | 269 | switch message.Type { 270 | case "status": 271 | s := message.Data.(*WSMessageDataStatus) 272 | if c.callbacks != nil && c.callbacks.ClientQueueCountChanged != nil { 273 | c.queuecount = s.Status.ExecInfo.QueueRemaining 274 | c.callbacks.ClientQueueCountChanged(c, s.Status.ExecInfo.QueueRemaining) 275 | } 276 | case "execution_start": 277 | s := message.Data.(*WSMessageDataExecutionStart) 278 | qi := c.GetQueuedItem(s.PromptID) 279 | // update lastProcessedPromptID to indicate we are processing a new prompt 280 | c.lastProcessedPromptID = s.PromptID 281 | if qi != nil { 282 | if c.callbacks != nil && c.callbacks.QueuedItemStarted != nil { 283 | c.callbacks.QueuedItemStarted(c, qi) 284 | } 285 | m := PromptMessage{ 286 | Type: "started", 287 | Message: &PromptMessageStarted{ 288 | PromptID: qi.PromptID, 289 | }, 290 | } 291 | qi.Messages <- m 292 | } 293 | case "execution_cached": 294 | // this is probably not usefull for us 295 | case "executing": 296 | s := message.Data.(*WSMessageDataExecuting) 297 | qi := c.GetQueuedItem(s.PromptID) 298 | 299 | if qi != nil { 300 | if s.Node == nil { 301 | // final node was processed 302 | m := PromptMessage{ 303 | Type: "stopped", 304 | Message: &PromptMessageStopped{ 305 | QueueItem: qi, 306 | Exception: nil, 307 | }, 308 | } 309 | // remove the Item from our Queue before sending the message 310 | // no other messages will be sent to the channel after this 311 | if c.callbacks != nil && c.callbacks.QueuedItemStopped != nil { 312 | c.callbacks.QueuedItemStopped(c, qi, QueuedItemStoppedReasonFinished) 313 | } 314 | delete(c.queueditems, qi.PromptID) 315 | qi.Messages <- m 316 | } else { 317 | node := qi.Workflow.GetNodeById(*s.Node) 318 | m := PromptMessage{ 319 | Type: "executing", 320 | Message: &PromptMessageExecuting{ 321 | NodeID: *s.Node, 322 | Title: node.DisplayName, 323 | }, 324 | } 325 | qi.Messages <- m 326 | } 327 | } 328 | case "progress": 329 | s := message.Data.(*WSMessageDataProgress) 330 | qi := c.GetQueuedItem(c.lastProcessedPromptID) 331 | if qi != nil { 332 | m := PromptMessage{ 333 | Type: "progress", 334 | Message: &PromptMessageProgress{ 335 | Value: s.Value, 336 | Max: s.Max, 337 | }, 338 | } 339 | qi.Messages <- m 340 | } 341 | case "executed": 342 | s := message.Data.(*WSMessageDataExecuted) 343 | qi := c.GetQueuedItem(s.PromptID) 344 | if qi != nil { 345 | // mdata := &PromptMessageData{ 346 | // NodeID: s.Node, 347 | // Images: *s.Output["images"], 348 | // } 349 | 350 | // collect the data from the output 351 | mdata := &PromptMessageData{ 352 | NodeID: s.Node, 353 | Data: make(map[string][]DataOutput), 354 | } 355 | 356 | for k, v := range s.Output { 357 | mdata.Data[k] = *v 358 | } 359 | 360 | m := PromptMessage{ 361 | Type: "data", 362 | Message: mdata, 363 | } 364 | if c.callbacks != nil && c.callbacks.QueuedItemDataAvailable != nil { 365 | c.callbacks.QueuedItemDataAvailable(c, qi, mdata) 366 | } 367 | qi.Messages <- m 368 | } 369 | case "execution_interrupted": 370 | s := message.Data.(*WSMessageExecutionInterrupted) 371 | qi := c.GetQueuedItem(s.PromptID) 372 | if qi != nil { 373 | m := PromptMessage{ 374 | Type: "stopped", 375 | Message: &PromptMessageStopped{ 376 | QueueItem: qi, 377 | Exception: nil, 378 | }, 379 | } 380 | // remove the Item from our Queue before sending the message 381 | // no other messages will be sent to the channel after this 382 | if c.callbacks != nil && c.callbacks.QueuedItemStopped != nil { 383 | c.callbacks.QueuedItemStopped(c, qi, QueuedItemStoppedReasonInterrupted) 384 | } 385 | delete(c.queueditems, qi.PromptID) 386 | qi.Messages <- m 387 | } 388 | case "execution_error": 389 | s := message.Data.(*WSMessageExecutionError) 390 | qi := c.GetQueuedItem(s.PromptID) 391 | if qi != nil { 392 | nindex, _ := strconv.Atoi(s.Node) // the node id is serialized as a string 393 | tnode := qi.Workflow.GetNodeById(nindex) 394 | m := PromptMessage{ 395 | Type: "stopped", 396 | Message: &PromptMessageStopped{ 397 | QueueItem: qi, 398 | Exception: &PromptMessageStoppedException{ 399 | NodeID: nindex, 400 | NodeType: s.NodeType, 401 | NodeName: tnode.Title, 402 | ExceptionMessage: s.ExceptionMessage, 403 | ExceptionType: s.ExceptionType, 404 | Traceback: s.Traceback, 405 | }, 406 | }, 407 | } 408 | // remove the Item from our Queue before sending the message 409 | // no other messages will be sent to the channel after this 410 | if c.callbacks != nil && c.callbacks.QueuedItemStopped != nil { 411 | c.callbacks.QueuedItemStopped(c, qi, QueuedItemStoppedReasonError) 412 | } 413 | delete(c.queueditems, qi.PromptID) 414 | qi.Messages <- m 415 | } 416 | case "crystools.monitor": 417 | default: 418 | // Handle unknown data types or return a dedicated error here 419 | slog.Warn("Unhandled message type: ", "type", message.Type) 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /graphapi/graph.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "os" 9 | 10 | "sort" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | // allow us to order nodes by thier execution order (ordinality) 16 | type ByGraphOrdinal []*GraphNode 17 | 18 | func (a ByGraphOrdinal) Len() int { return len(a) } 19 | func (a ByGraphOrdinal) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 20 | func (a ByGraphOrdinal) Less(i, j int) bool { return a[i].Order < a[j].Order } 21 | 22 | type Graph struct { 23 | Nodes []*GraphNode `json:"nodes"` 24 | Links []*Link `json:"links"` 25 | Groups []*Group `json:"groups"` 26 | LastNodeID int `json:"last_node_id"` 27 | LastLinkID int `json:"last_link_id"` 28 | Version float32 `json:"version"` 29 | NodesByID map[int]*GraphNode `json:"-"` 30 | LinksByID map[int]*Link `json:"-"` 31 | NodesInExecutionOrder []*GraphNode `json:"-"` 32 | HasErrors bool `json:"-"` 33 | } 34 | 35 | // GetGroupWithTitle returns the 'first' group with the given title 36 | func (t *Graph) GetGroupWithTitle(title string) *Group { 37 | for _, g := range t.Groups { 38 | if g.Title == title { 39 | return g 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | func (t *Graph) GetNodesInGroup(g *Group) []*GraphNode { 46 | retv := make([]*GraphNode, 0) 47 | for _, n := range t.Nodes { 48 | if g.IntersectsOrContains(n) { 49 | retv = append(retv, n) 50 | } 51 | } 52 | return retv 53 | } 54 | 55 | func (t *Graph) UnmarshalJSON(b []byte) error { 56 | // Create an alias type to avoid recursive call to UnmarshalJSON 57 | type Alias Graph 58 | 59 | alias := &Alias{} 60 | 61 | if err := json.Unmarshal(b, alias); err != nil { 62 | return err 63 | } 64 | 65 | // Copy the fields from the alias to the original struct 66 | t.Nodes = alias.Nodes 67 | t.Links = alias.Links 68 | t.Groups = alias.Groups 69 | t.LastNodeID = alias.LastNodeID 70 | t.LastLinkID = alias.LastLinkID 71 | t.Version = alias.Version 72 | t.NodesByID = make(map[int]*GraphNode) 73 | t.LinksByID = make(map[int]*Link) 74 | 75 | for _, node := range t.Nodes { 76 | // Populate the "by ID's" 77 | t.NodesByID[node.ID] = node 78 | // Give the node a pointer to it's parent graph 79 | t.NodesByID[node.ID].Graph = t 80 | } 81 | 82 | for _, link := range t.Links { 83 | t.LinksByID[link.ID] = link 84 | } 85 | 86 | // get the ordinality of nodes 87 | t.NodesInExecutionOrder = make([]*GraphNode, len(t.Nodes)) 88 | copy(t.NodesInExecutionOrder, t.Nodes) 89 | sort.Sort(ByGraphOrdinal(t.NodesInExecutionOrder)) 90 | 91 | return nil 92 | } 93 | 94 | func duplicateProperty(prop Property) Property { 95 | switch prop.TypeString() { 96 | case "STRING": 97 | np := *prop.(*StringProperty) 98 | return &np 99 | case "FLOAT": 100 | np := *prop.(*FloatProperty) 101 | return &np 102 | case "COMBO": 103 | np := *prop.(*ComboProperty) 104 | return &np 105 | case "INT": 106 | np := *prop.(*IntProperty) 107 | return &np 108 | case "UNKNOWN": 109 | np := *prop.(*UnknownProperty) 110 | return &np 111 | } 112 | slog.Warn("Cannot duplicate property of unknown type") 113 | return nil 114 | } 115 | 116 | func containsString(slice *[]string, target string) bool { 117 | for _, item := range *slice { 118 | if item == target { 119 | return true 120 | } 121 | } 122 | return false 123 | } 124 | 125 | // CreateNodeProperties generates the properties required to allow setting values 126 | // 127 | // Parameters: 128 | // - node_objects: NodeObjects returned from server 129 | // 130 | // Returns: 131 | // - A pointer to an array of strings containing any missing nodes in the node_objects 132 | func (t *Graph) CreateNodeProperties(node_objects *NodeObjects) *[]string { 133 | // we'll store primitives and process them after all other nodes have 134 | // had thier properties created 135 | primitives := make([]*GraphNode, 0) 136 | var retv *[]string = nil 137 | for _, n := range t.Nodes { 138 | pindex := 0 139 | 140 | // random numbers seem to have an additional widget added in widget.js addValueControlWidget @ln 15 141 | // when an INT widget is created with either the name "seed" or "noise_seed", the additional 142 | // widget is added directly after. 143 | // it is a COMBO called "control_after_generate" with one of: 144 | // fixed 145 | // increment 146 | // decrement 147 | // randomize 148 | 149 | // create a new map to hold the properties by name 150 | n.Properties = make(map[string]Property) 151 | nobject := node_objects.GetNodeObjectByName(n.Type) 152 | 153 | if nobject != nil { 154 | // get the display name and description 155 | n.DisplayName = nobject.DisplayName 156 | n.Description = nobject.Description 157 | 158 | // is this node an output node? 159 | n.IsOutput = nobject.OutputNode 160 | 161 | // get the settable properties and associate them with correct widgets 162 | props := nobject.GetSettableProperties() 163 | t.ProcessSettableProperties(n, &props, &pindex) 164 | 165 | // check if the number of properties is the same as the number of widget values 166 | if n.WidgetValueCount() != len(props) { 167 | // If the count of WidgetValues is not the same as props there may be potential issues 168 | // which may arrise here if not handled properly. An example is LoadImage and LoadImageMask where 169 | // there is a widget "choose file to upload" whose field points to the 170 | // property that the upload would be set to. This widget is added in web/extensions/core/uploadImage.js 171 | if nobject.Name == "LoadImage" || nobject.Name == "LoadImageMask" { 172 | // create an imageuploader property and point to it's associated COMBO property 173 | targetProp := n.GetPropertyWithName("image") 174 | if targetProp != nil { 175 | np := newImageUploadProperty("choose file to upload", targetProp.(*ComboProperty), len(n.Properties)) 176 | // set the alias to "file" 177 | (*np).SetAlias("file") 178 | n.Properties["choose file to upload"] = *np 179 | } else { 180 | slog.Error("Cannot find \"image\" property") 181 | } 182 | } else { 183 | slog.Debug("size missmatch for", "node type", n.Type) 184 | } 185 | } 186 | } else { 187 | if n.Type == "PrimitiveNode" { 188 | primitives = append(primitives, n) 189 | } else if n.Type == "Note" { 190 | notewidgets := n.WidgetValues.([]interface{}) 191 | // get the pointer to the first widget value 192 | // we'll set the property direct_value to point to the widget inteface we want to target 193 | np := newStringProperty("text", false, nil, 0) 194 | (*np).SetDirectValue(¬ewidgets[0]) 195 | n.Properties["text"] = *np 196 | continue 197 | } else if n.Type == "Reroute" { 198 | // skip Reroute 199 | continue 200 | } else { 201 | slog.Error("Could not get node object for", "node type", n.Type) 202 | if retv == nil { 203 | r := make([]string, 0) 204 | retv = &r 205 | } 206 | if !containsString(retv, n.Type) { 207 | r := append(*retv, n.Type) 208 | retv = &r 209 | } 210 | } 211 | } 212 | } 213 | 214 | // process primitives 215 | // Can a primitive?: 216 | // Connect to reroute: Nope (thank god) 217 | // Connect combo to two different types: Nope 218 | for _, primitive_node := range primitives { 219 | for _, primitive_node_output := range primitive_node.Outputs { 220 | // For outputs, we need to contend with multiple links. 221 | // Go through each output, get the link, then the target node, 222 | // then the target property of that node. 223 | if primitive_node_output.Links != nil && len(*primitive_node_output.Links) != 0 { 224 | // we'll use the type and value of primitive_node_output.Links[0]. I'll assume that. 225 | // the link IDs are ordered and [0] would be the first on linked 226 | var first_property Property 227 | pindex := 0 228 | for _, l := range *primitive_node_output.Links { 229 | primitive_node_output_link := t.GetLinkById(l) 230 | if primitive_node_output_link != nil { 231 | // get the target node 232 | target_node := t.GetNodeById(primitive_node_output_link.TargetID) 233 | if target_node != nil { 234 | if first_property == nil { 235 | first_property = target_node.Inputs[primitive_node_output_link.TargetSlot].Property 236 | if first_property == nil { 237 | slog.Warn("Could not get primitive target slot property %s for node %s", target_node.Inputs[primitive_node_output_link.TargetSlot].Name, target_node.Title) 238 | continue 239 | } 240 | // copy the property and assign it the node's "value" property 241 | np := duplicateProperty(first_property) 242 | np.SetIndex(pindex) 243 | primitive_node.Properties["value"] = np 244 | } else { 245 | // copy the property and add the node's "value" property as a secondary 246 | p := target_node.Inputs[primitive_node_output_link.TargetSlot].Property 247 | if p != nil { 248 | newp := duplicateProperty(p) 249 | newp.SetIndex(pindex) 250 | primitive_node.Properties["value"].AttachSecondaryProperty(newp) 251 | } 252 | } 253 | } 254 | } 255 | pindex++ 256 | } 257 | } 258 | } 259 | } 260 | return retv 261 | } 262 | 263 | func (t *Graph) ProcessSettableProperties(n *GraphNode, props *[]Property, pindex *int) { 264 | for _, prop := range *props { 265 | // convert to actual property type, deep copy 266 | // store a pointer to the property in the node's 267 | // correct Input 268 | switch prop.TypeString() { 269 | case "STRING": 270 | np := *prop.(*StringProperty) 271 | np.UpdateParent(&np) 272 | np.SetTargetWidget(n, *pindex) 273 | *pindex++ 274 | n.Properties[prop.Name()] = &np 275 | n.affixPropertyToInputSlot(prop.Name(), &np) 276 | case "FLOAT": 277 | np := *prop.(*FloatProperty) 278 | np.UpdateParent(&np) 279 | np.SetTargetWidget(n, *pindex) 280 | *pindex++ 281 | n.Properties[prop.Name()] = &np 282 | n.affixPropertyToInputSlot(prop.Name(), &np) 283 | case "COMBO": 284 | np := *prop.(*ComboProperty) 285 | np.UpdateParent(&np) 286 | np.SetTargetWidget(n, *pindex) 287 | *pindex++ 288 | n.Properties[prop.Name()] = &np 289 | n.affixPropertyToInputSlot(prop.Name(), &np) 290 | case "INT": 291 | np := *prop.(*IntProperty) 292 | np.UpdateParent(&np) 293 | np.SetTargetWidget(n, *pindex) 294 | *pindex++ 295 | n.Properties[prop.Name()] = &np 296 | n.affixPropertyToInputSlot(prop.Name(), &np) 297 | case "BOOLEAN": 298 | np := *prop.(*BoolProperty) 299 | np.UpdateParent(&np) 300 | np.SetTargetWidget(n, *pindex) 301 | *pindex++ 302 | n.Properties[prop.Name()] = &np 303 | n.affixPropertyToInputSlot(prop.Name(), &np) 304 | case "CASCADE": 305 | // find the widget in the target node 306 | wmap := n.WidgetValuesMap() 307 | // get the widget value 308 | wv, ok := wmap[prop.Name()] 309 | if !ok { 310 | slog.Warn("Cannot find widget value for", "property", prop.Name()) 311 | continue 312 | } 313 | // get the widget's string value 314 | wvstr, ok := wv.(string) 315 | if !ok { 316 | slog.Warn("Cannot convert widget value to string for", "property", prop.Name()) 317 | continue 318 | } 319 | 320 | // get the cascade group with the same name as the widget value 321 | cg := prop.(*CascadingProperty).GetGroupByName(wvstr) 322 | if cg == nil { 323 | slog.Warn("Cannot find cascade group for", "widget", wvstr) 324 | continue 325 | } 326 | groupproperties := cg.Properties() 327 | 328 | np := *prop.(*CascadingProperty) 329 | np.UpdateParent(&np) 330 | np.SetTargetWidget(n, *pindex) 331 | *pindex++ 332 | n.Properties[prop.Name()] = &np 333 | n.affixPropertyToInputSlot(prop.Name(), &np) 334 | 335 | // now append the cascade group's properties 336 | t.ProcessSettableProperties(n, &groupproperties, pindex) 337 | case "UNKNOWN": 338 | slog.Warn("UNKNOWN property type in settable field") 339 | np := *prop.(*UnknownProperty) 340 | np.UpdateParent(&np) 341 | np.SetTargetWidget(n, *pindex) 342 | *pindex++ 343 | n.Properties[prop.Name()] = &np 344 | n.affixPropertyToInputSlot(prop.Name(), &np) 345 | } 346 | } 347 | } 348 | 349 | func (t *Graph) GetLinkById(id int) *Link { 350 | val, ok := t.LinksByID[id] 351 | if ok { 352 | return val 353 | } 354 | return nil 355 | } 356 | 357 | func (t *Graph) GetNodeById(id int) *GraphNode { 358 | val, ok := t.NodesByID[id] 359 | if ok { 360 | return val 361 | } 362 | return nil 363 | } 364 | 365 | // GetNodesWithTitle retrieves nodes from the graph based on a given title. If a node's title is not set, 366 | // it falls back to matching against the node's display name. 367 | // 368 | // Parameters: 369 | // - title: The title (or display name if title is absent) to filter nodes by. 370 | // 371 | // Returns: 372 | // - A slice of pointers to GraphNodes that match the specified title or display name. 373 | func (t *Graph) GetNodesWithTitle(title string) []*GraphNode { 374 | retv := make([]*GraphNode, 0) 375 | for _, n := range t.Nodes { 376 | if (n.Title == "" && n.DisplayName == title) || n.Title == title { 377 | retv = append(retv, n) 378 | } 379 | } 380 | return retv 381 | } 382 | 383 | // GetFirstNodeWithTitle retrieves the first node from the graph based on a given title. If a node's title is not set, 384 | // it falls back to matching against the node's display name. 385 | // 386 | // Parameters: 387 | // - title: The title (or display name if title is absent) to filter nodes by. 388 | // 389 | // Returns: 390 | // - A pointer to a GraphNode 391 | func (t *Graph) GetFirstNodeWithTitle(title string) *GraphNode { 392 | nodes := t.GetNodesWithTitle(title) 393 | if len(nodes) != 0 { 394 | return nodes[0] 395 | } 396 | return nil 397 | } 398 | 399 | // GetNodesWithType retrieves all nodes in the graph that match a specified type. 400 | // 401 | // Parameters: 402 | // - nodeType: The type of node to filter by. 403 | // 404 | // Returns: 405 | // - A slice of pointers to GraphNodes that match the specified type. 406 | func (t *Graph) GetNodesWithType(nodeType string) []*GraphNode { 407 | retv := make([]*GraphNode, 0) 408 | for _, n := range t.Nodes { 409 | if n.Type == nodeType { 410 | retv = append(retv, n) 411 | } 412 | } 413 | return retv 414 | } 415 | 416 | func NewGraphFromJsonReader(r io.Reader, node_objects *NodeObjects) (*Graph, *[]string, error) { 417 | fileContent, err := io.ReadAll(r) 418 | if err != nil { 419 | return nil, nil, err 420 | } 421 | 422 | // deserialize workflow into a graph 423 | text := string(fileContent) 424 | graph := &Graph{} 425 | err = json.Unmarshal([]byte(text), &graph) 426 | if err != nil { 427 | return nil, nil, err 428 | } 429 | missing := graph.CreateNodeProperties(node_objects) 430 | if missing != nil && len(*missing) != 0 { 431 | err = errors.New("missing node types") 432 | } 433 | return graph, missing, err 434 | } 435 | 436 | func NewGraphFromJsonFile(path string, node_objects *NodeObjects) (*Graph, *[]string, error) { 437 | freader, err := os.Open(path) 438 | if err != nil { 439 | return nil, nil, err 440 | } 441 | defer freader.Close() 442 | 443 | return NewGraphFromJsonReader(freader, node_objects) 444 | } 445 | 446 | func NewGraphFromJsonString(data string, node_objects *NodeObjects) (*Graph, *[]string, error) { 447 | // Convert the string to an io.Reader 448 | reader := strings.NewReader(data) 449 | return NewGraphFromJsonReader(reader, node_objects) 450 | } 451 | 452 | func (t *Graph) GraphToJSON() (string, error) { 453 | data, err := json.Marshal(t) 454 | if err != nil { 455 | return "", err 456 | } 457 | return string(data), nil 458 | } 459 | 460 | func (t *Graph) SaveGraphToFile(path string) error { 461 | data, err := t.GraphToJSON() 462 | if err != nil { 463 | return err 464 | } 465 | file, err := os.Create(path) 466 | if err != nil { 467 | return err 468 | } 469 | defer file.Close() 470 | _, err = file.WriteString(data) 471 | if err != nil { 472 | return err 473 | } 474 | return nil 475 | } 476 | 477 | func (t *Graph) GraphToPrompt(clientID string) (Prompt, error) { 478 | p := Prompt{ 479 | ClientID: clientID, 480 | Nodes: make(map[int]PromptNode), 481 | // PID: "floopy-thingy-ma-bob", // we can add additionl information that is ignored by ComfyUI 482 | } 483 | for _, node := range t.NodesInExecutionOrder { 484 | if node.IsVirtual() { 485 | // Don't serialize frontend only nodes but let them make changes 486 | node.ApplyToGraph() 487 | continue 488 | } 489 | 490 | if node.Mode == 2 { 491 | // Don't serialize muted nodes 492 | continue 493 | } 494 | 495 | // create the prompt node 496 | pn := PromptNode{ 497 | ClassType: node.Type, 498 | Inputs: make(map[string]interface{}), 499 | } 500 | 501 | // populate the node input values 502 | for k, prop := range node.Properties { 503 | if prop.Serializable() { 504 | pn.Inputs[k] = prop.GetValue() 505 | } 506 | } 507 | 508 | // populate the node input links 509 | for i, slot := range node.Inputs { 510 | parent := node.GetNodeForInput(i) 511 | if parent != nil { 512 | link := t.GetLinkById(slot.Link) 513 | for parent != nil && parent.IsVirtual() { 514 | link = parent.GetInputLink(link.OriginSlot) 515 | if link != nil { 516 | parent = parent.GetNodeForInput(link.OriginSlot) 517 | } else { 518 | break 519 | } 520 | } 521 | 522 | if link != nil { 523 | linfo := make([]interface{}, 2) 524 | linfo[0] = strconv.Itoa(link.OriginID) 525 | linfo[1] = link.OriginSlot 526 | pn.Inputs[node.Inputs[i].Name] = linfo 527 | } 528 | } 529 | } 530 | p.Nodes[node.ID] = pn 531 | } 532 | // assign our current graph as the workflow 533 | p.ExtraData.PngInfo.Workflow = t 534 | return p, nil 535 | } 536 | -------------------------------------------------------------------------------- /graphapi/properties.go: -------------------------------------------------------------------------------- 1 | package graphapi 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "math" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // Property is a node's input that can be a settable value 14 | // Settable property types: 15 | // "INT" an int64 16 | // "FLOAT" a float64 17 | // "STRING" a single line, or multiline string 18 | // "COMBO" one of a given list of strings 19 | // "BOOLEAN" a labeled bool value 20 | // "IMAGEUPLOAD" image uploader 21 | // "CASCADE" collection cascading style properties 22 | // "UNKNOWN" everything else (unsettable) 23 | type Property interface { 24 | TypeString() string 25 | Optional() bool 26 | Settable() bool 27 | Name() string 28 | SetTargetWidget(node *GraphNode, index int) 29 | GetTargetWidget() int 30 | GetTargetNode() *GraphNode 31 | GetValue() interface{} 32 | SetValue(v interface{}) error 33 | Serializable() bool 34 | SetSerializable(bool) 35 | AttachSecondaryProperty(p Property) 36 | Index() int 37 | SetIndex(index int) 38 | TargetIndex() int 39 | SetAlias(string) 40 | GetAlias() string 41 | 42 | UpdateParent(parent Property) 43 | ToIntProperty() (*IntProperty, bool) 44 | ToFloatProperty() (*FloatProperty, bool) 45 | ToBoolProperty() (*BoolProperty, bool) 46 | ToStringProperty() (*StringProperty, bool) 47 | ToComboProperty() (*ComboProperty, bool) 48 | ToCascadeProperty() (*CascadingProperty, bool) 49 | ToImageUploadProperty() (*ImageUploadProperty, bool) 50 | ToUnknownProperty() (*UnknownProperty, bool) 51 | valueFromString(value string) interface{} 52 | 53 | SetDirectValue(v *interface{}) 54 | } 55 | 56 | type BaseProperty struct { 57 | parent Property 58 | name string 59 | optional bool 60 | target_node *GraphNode 61 | target_value_index int 62 | serializable bool 63 | secondaries []Property 64 | override_property interface{} // if non-nil, this value will be serialized 65 | index int 66 | direct_value *interface{} 67 | alias string 68 | } 69 | 70 | func (b *BaseProperty) SetDirectValue(v *interface{}) { 71 | b.direct_value = v 72 | b.serializable = false 73 | } 74 | 75 | func (b *BaseProperty) UpdateParent(parent Property) { 76 | b.parent = parent 77 | } 78 | 79 | func (b *BaseProperty) Serializable() bool { 80 | return b.serializable 81 | } 82 | 83 | func (b *BaseProperty) SetSerializable(val bool) { 84 | b.serializable = val 85 | } 86 | 87 | func (b *BaseProperty) AttachSecondaryProperty(p Property) { 88 | if b.secondaries == nil { 89 | b.secondaries = make([]Property, 0) 90 | } 91 | b.secondaries = append(b.secondaries, p) 92 | } 93 | 94 | func (b *BaseProperty) SetTargetWidget(node *GraphNode, index int) { 95 | b.target_node = node 96 | b.target_value_index = index 97 | } 98 | 99 | func (b *BaseProperty) GetTargetWidget() int { 100 | return b.target_value_index 101 | } 102 | 103 | func (b *BaseProperty) GetTargetNode() *GraphNode { 104 | return b.target_node 105 | } 106 | 107 | func (b *BaseProperty) GetValue() interface{} { 108 | if b.override_property != nil { 109 | return b.override_property 110 | } 111 | 112 | if b.direct_value != nil { 113 | return b.direct_value 114 | } 115 | 116 | if b.target_node != nil { 117 | if b.target_node.IsWidgetValueArray() { 118 | if b.target_value_index < len(b.target_node.WidgetValuesArray()) { 119 | return b.target_node.WidgetValuesArray()[b.target_value_index] 120 | } 121 | return nil 122 | } else { 123 | return b.target_node.WidgetValuesMap()[b.name] 124 | } 125 | } 126 | return nil 127 | } 128 | 129 | // SetValue calls the protocol implementation for valueFromString to get 130 | // the actual value that will be set. valueFromString should perform 131 | // conversion to its native type and constrain it when needed 132 | func (b *BaseProperty) SetValue(v interface{}) error { 133 | // convert the value to a string first 134 | var vs string 135 | 136 | // if v is of type float64 or float32 and the property is an int, convert it to an int 137 | if b.parent.TypeString() == "INT" { 138 | if f, ok := v.(float64); ok { 139 | vs = fmt.Sprintf("%d", int64(f)) 140 | } else if f, ok := v.(float32); ok { 141 | vs = fmt.Sprintf("%d", int64(f)) 142 | } else { 143 | vs = fmt.Sprintf("%v", v) 144 | } 145 | } else { 146 | vs = fmt.Sprintf("%v", v) 147 | } 148 | 149 | val := b.parent.valueFromString(vs) 150 | if val == nil { 151 | return errors.New("could not get converted type") 152 | } 153 | 154 | if b.direct_value != nil { 155 | *b.direct_value = val 156 | return nil 157 | } 158 | 159 | if b.target_node != nil { 160 | if b.target_node.IsWidgetValueArray() { 161 | b.target_node.WidgetValuesArray()[b.target_value_index] = val 162 | } else { 163 | b.target_node.WidgetValuesMap()[b.name] = val 164 | } 165 | } else { 166 | return errors.New("property has no target node") 167 | } 168 | 169 | // if there are secondaries, set those too 170 | if b.secondaries != nil { 171 | for _, p := range b.secondaries { 172 | err := p.SetValue(val) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | func (b *BaseProperty) Index() int { 182 | return b.index 183 | } 184 | 185 | func (b *BaseProperty) SetIndex(index int) { 186 | b.index = index 187 | } 188 | 189 | func (b *BaseProperty) TargetIndex() int { 190 | return b.target_value_index 191 | } 192 | 193 | func (b *BaseProperty) ToIntProperty() (*IntProperty, bool) { 194 | if prop, ok := b.parent.(*IntProperty); ok { 195 | return prop, true 196 | } 197 | return nil, false 198 | } 199 | func (b *BaseProperty) ToFloatProperty() (*FloatProperty, bool) { 200 | if prop, ok := b.parent.(*FloatProperty); ok { 201 | return prop, true 202 | } 203 | return nil, false 204 | } 205 | func (b *BaseProperty) ToBoolProperty() (*BoolProperty, bool) { 206 | if prop, ok := b.parent.(*BoolProperty); ok { 207 | return prop, true 208 | } 209 | return nil, false 210 | } 211 | func (b *BaseProperty) ToStringProperty() (*StringProperty, bool) { 212 | if prop, ok := b.parent.(*StringProperty); ok { 213 | return prop, true 214 | } 215 | return nil, false 216 | } 217 | func (b *BaseProperty) ToComboProperty() (*ComboProperty, bool) { 218 | if prop, ok := b.parent.(*ComboProperty); ok { 219 | return prop, true 220 | } 221 | return nil, false 222 | } 223 | func (b *BaseProperty) ToCascadeProperty() (*CascadingProperty, bool) { 224 | if prop, ok := b.parent.(*CascadingProperty); ok { 225 | return prop, true 226 | } 227 | return nil, false 228 | } 229 | func (b *BaseProperty) ToImageUploadProperty() (*ImageUploadProperty, bool) { 230 | if prop, ok := b.parent.(*ImageUploadProperty); ok { 231 | return prop, true 232 | } 233 | return nil, false 234 | } 235 | func (b *BaseProperty) ToUnknownProperty() (*UnknownProperty, bool) { 236 | if prop, ok := b.parent.(*UnknownProperty); ok { 237 | return prop, true 238 | } 239 | return nil, false 240 | } 241 | 242 | func (b *BaseProperty) SetAlias(a string) { 243 | b.alias = a 244 | } 245 | 246 | func (b *BaseProperty) GetAlias() string { 247 | return b.alias 248 | } 249 | 250 | type BoolProperty struct { 251 | BaseProperty 252 | Default bool 253 | LabelOn string 254 | LabelOff string 255 | } 256 | 257 | func newBoolProperty(input_name string, optional bool, data interface{}, index int) *Property { 258 | c := &BoolProperty{ 259 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 260 | Default: false, 261 | } 262 | c.parent = c 263 | 264 | if d, ok := data.(map[string]interface{}); ok { 265 | if val, ok := d["label_off"]; ok { 266 | c.LabelOn = val.(string) 267 | } 268 | 269 | if val, ok := d["label_off"]; ok { 270 | c.LabelOn = val.(string) 271 | } 272 | 273 | if val, ok := d["label_off"]; ok { 274 | c.Default = val.(bool) 275 | } 276 | } 277 | 278 | var retv Property = c 279 | return &retv 280 | } 281 | func (p *BoolProperty) TypeString() string { 282 | return "BOOLEAN" 283 | } 284 | func (p *BoolProperty) Optional() bool { 285 | return p.optional 286 | } 287 | func (p *BoolProperty) Settable() bool { 288 | return true 289 | } 290 | func (p *BoolProperty) Name() string { 291 | return p.name 292 | } 293 | func (p *BoolProperty) valueFromString(value string) interface{} { 294 | v, err := strconv.ParseBool(value) 295 | if err != nil { 296 | return nil 297 | } 298 | return v 299 | } 300 | 301 | type IntProperty struct { 302 | BaseProperty 303 | Default int64 304 | Min int64 // optional 305 | Max int64 // optional 306 | Step int64 // optional 307 | hasStep bool 308 | hasRange bool 309 | } 310 | 311 | func newIntProperty(input_name string, optional bool, data interface{}, index int) *Property { 312 | c := &IntProperty{ 313 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 314 | Default: 0, 315 | Min: 0, 316 | Max: math.MaxInt64, 317 | Step: 0, 318 | hasRange: false, 319 | hasStep: false, 320 | } 321 | c.parent = Property(c) 322 | 323 | if d, ok := data.(map[string]interface{}); ok { 324 | // min? 325 | if val, ok := d["min"]; ok { 326 | floatVal := val.(float64) 327 | if floatVal > float64(math.MaxInt64) { 328 | c.Min = math.MaxInt64 329 | } else if floatVal < float64(math.MinInt64) { 330 | c.Min = math.MinInt64 331 | } else { 332 | c.Min = int64(floatVal) 333 | } 334 | c.hasRange = true 335 | } 336 | 337 | // max? 338 | if val, ok := d["max"]; ok { 339 | floatVal := val.(float64) 340 | if floatVal > float64(math.MaxInt64) { 341 | c.Max = math.MaxInt64 342 | } else if floatVal < float64(math.MinInt64) { 343 | c.Max = math.MinInt64 344 | } else { 345 | c.Max = int64(floatVal) 346 | } 347 | c.hasRange = true 348 | } 349 | 350 | // step? 351 | if val, ok := d["step"]; ok { 352 | floatVal := val.(float64) 353 | if floatVal > float64(math.MaxInt64) { 354 | c.Step = math.MaxInt64 355 | } else if floatVal < float64(math.MinInt64) { 356 | c.Step = math.MinInt64 357 | } else { 358 | c.Step = int64(floatVal) 359 | } 360 | c.hasStep = true 361 | } 362 | 363 | if c.hasRange && c.Min > c.Max { 364 | c.Min = 0 365 | c.Max = math.MaxInt64 366 | } 367 | } 368 | 369 | var retv Property = c 370 | return &retv 371 | } 372 | func (p *IntProperty) TypeString() string { 373 | return "INT" 374 | } 375 | func (p *IntProperty) Optional() bool { 376 | return p.optional 377 | } 378 | func (p *IntProperty) HasStep() bool { 379 | return p.hasStep 380 | } 381 | func (p *IntProperty) HasRange() bool { 382 | return p.hasRange 383 | } 384 | func (p *IntProperty) Settable() bool { 385 | return true 386 | } 387 | func (p *IntProperty) Name() string { 388 | return p.name 389 | } 390 | func (p *IntProperty) valueFromString(value string) interface{} { 391 | v, err := strconv.ParseInt(value, 10, 64) 392 | if err != nil { 393 | return nil 394 | } 395 | if p.hasRange { 396 | v = int64(math.Min(float64(p.Max), float64(v))) 397 | v = int64(math.Max(float64(p.Min), float64(v))) 398 | } 399 | return v 400 | } 401 | 402 | type FloatProperty struct { 403 | BaseProperty 404 | Default float64 405 | Min float64 406 | Max float64 407 | Step float64 408 | hasStep bool 409 | hasRange bool 410 | } 411 | 412 | func newFloatProperty(input_name string, optional bool, data interface{}, index int) *Property { 413 | c := &FloatProperty{ 414 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 415 | Default: 0, 416 | Min: 0, 417 | Max: math.MaxFloat64, 418 | Step: 0, 419 | hasStep: false, 420 | hasRange: false, 421 | } 422 | c.parent = c 423 | 424 | if d, ok := data.(map[string]interface{}); ok { 425 | // min? 426 | if val, ok := d["min"]; ok { 427 | c.Min = val.(float64) 428 | c.hasRange = true 429 | } 430 | 431 | // max? 432 | if val, ok := d["max"]; ok { 433 | c.Max = val.(float64) 434 | c.hasRange = true 435 | } 436 | 437 | // step? 438 | if val, ok := d["step"]; ok { 439 | c.Step = val.(float64) 440 | c.hasStep = true 441 | } 442 | } 443 | 444 | var retv Property = c 445 | return &retv 446 | } 447 | func (p *FloatProperty) TypeString() string { 448 | return "FLOAT" 449 | } 450 | func (p *FloatProperty) Optional() bool { 451 | return p.optional 452 | } 453 | func (p *FloatProperty) HasStep() bool { 454 | return p.hasStep 455 | } 456 | func (p *FloatProperty) HasRange() bool { 457 | return p.hasRange 458 | } 459 | func (p *FloatProperty) Settable() bool { 460 | return true 461 | } 462 | func (p *FloatProperty) Name() string { 463 | return p.name 464 | } 465 | func (p *FloatProperty) valueFromString(value string) interface{} { 466 | v, err := strconv.ParseFloat(value, 64) 467 | if err != nil { 468 | return nil 469 | } 470 | if p.hasRange { 471 | v = math.Min(v, p.Max) 472 | v = math.Max(v, p.Min) 473 | } 474 | return v 475 | } 476 | 477 | type StringProperty struct { 478 | BaseProperty 479 | Default string 480 | Multiline bool 481 | } 482 | 483 | func newStringProperty(input_name string, optional bool, data interface{}, index int) *Property { 484 | c := &StringProperty{ 485 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 486 | Default: "", 487 | Multiline: false, 488 | } 489 | c.parent = c 490 | 491 | if d, ok := data.(map[string]interface{}); ok { 492 | // default? 493 | if val, ok := d["default"]; ok { 494 | if s, ok := val.(string); ok { 495 | c.Default = s 496 | } 497 | } 498 | 499 | // multiline? 500 | if val, ok := d["multiline"]; ok { 501 | c.Multiline = val.(bool) 502 | } 503 | } 504 | 505 | var retv Property = c 506 | return &retv 507 | } 508 | func (p *StringProperty) TypeString() string { 509 | return "STRING" 510 | } 511 | func (p *StringProperty) Optional() bool { 512 | return p.optional 513 | } 514 | func (p *StringProperty) Settable() bool { 515 | return true 516 | } 517 | func (p *StringProperty) Name() string { 518 | return p.name 519 | } 520 | func (p *StringProperty) valueFromString(value string) interface{} { 521 | return value 522 | } 523 | 524 | func isCascadingProperty(input []interface{}) bool { 525 | for _, v := range input { 526 | if _, ok := v.([]interface{}); ok { 527 | return true 528 | } 529 | } 530 | return false 531 | } 532 | 533 | type CasdcadeEntry struct { 534 | Name string 535 | Property *Property 536 | } 537 | 538 | type CascadeGroup struct { 539 | Name string 540 | Entries []CasdcadeEntry 541 | } 542 | 543 | func (c *CascadeGroup) Properties() []Property { 544 | retv := make([]Property, 0) 545 | for _, e := range c.Entries { 546 | retv = append(retv, *e.Property) 547 | } 548 | return retv 549 | } 550 | 551 | type CascadingProperty struct { 552 | BaseProperty 553 | Groups []CascadeGroup 554 | SelectionIndex string 555 | } 556 | 557 | func newCascadeProperty(input_name string, optional bool, input []interface{}, index int) *Property { 558 | c := &CascadingProperty{ 559 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 560 | Groups: make([]CascadeGroup, 0), 561 | } 562 | c.parent = c 563 | 564 | for _, v := range input { 565 | if s, ok := v.(string); ok { 566 | // an empty cascade entry 567 | group := CascadeGroup{ 568 | Name: s, 569 | Entries: make([]CasdcadeEntry, 0), 570 | } 571 | c.Groups = append(c.Groups, group) 572 | } else { 573 | if e, ok := v.([]interface{}); ok { 574 | // the first value should be the name of the property group 575 | // the second value should be a slice of properties 576 | if len(e) == 2 { 577 | if cascadegroupname, ok := e[0].(string); ok { 578 | group := CascadeGroup{ 579 | Name: cascadegroupname, 580 | Entries: make([]CasdcadeEntry, 0), 581 | } 582 | if propgroups, ok := e[1].([]interface{}); ok { 583 | // create the properties 584 | // propgroups is a slice of property groups 585 | // each property group within propgroups is headed with the name of the entry follwed by the properties 586 | for _, pp := range propgroups { 587 | if propgroups, ok := pp.([]interface{}); ok { 588 | if len(propgroups) >= 2 { 589 | if propname, ok := propgroups[0].(string); ok { 590 | // convert the rest of the slice to []interface{} 591 | nparams := propgroups[1:] 592 | var paramAsInterface interface{} = nparams 593 | newprop := NewPropertyFromInput(propname, false, ¶mAsInterface, index) 594 | group.Entries = append(group.Entries, CasdcadeEntry{Name: propname, Property: newprop}) 595 | } else { 596 | slog.Debug("TODO - Potential non-string property name") 597 | } 598 | } else { 599 | slog.Debug("TODO - Potential non-slice property group") 600 | } 601 | } else { 602 | slog.Debug("TODO - Potential non-slice property group") 603 | } 604 | } 605 | } 606 | c.Groups = append(c.Groups, group) 607 | } 608 | } 609 | } 610 | } 611 | } 612 | var retv Property = c 613 | return &retv 614 | } 615 | 616 | func (p *CascadingProperty) TypeString() string { 617 | return "CASCADE" 618 | } 619 | 620 | func (p *CascadingProperty) Optional() bool { 621 | return p.optional 622 | } 623 | 624 | func (p *CascadingProperty) Settable() bool { 625 | // this would not be feasible in this architecture 626 | return true 627 | } 628 | 629 | func (p *CascadingProperty) Name() string { 630 | return p.name 631 | } 632 | 633 | func (p *CascadingProperty) valueFromString(value string) interface{} { 634 | // we can't set a cascading property directly 635 | return nil 636 | } 637 | 638 | func (p *CascadingProperty) GroupNames() []string { 639 | retv := make([]string, 0) 640 | for _, g := range p.Groups { 641 | retv = append(retv, g.Name) 642 | } 643 | return retv 644 | } 645 | 646 | func (p *CascadingProperty) GetGroupByName(name string) *CascadeGroup { 647 | for i := range p.Groups { 648 | if p.Groups[i].Name == name { 649 | return &p.Groups[i] 650 | } 651 | } 652 | return nil 653 | } 654 | 655 | type ComboProperty struct { 656 | BaseProperty 657 | Values []string 658 | IsBool bool 659 | } 660 | 661 | func newComboProperty(input_name string, optional bool, input []interface{}, index int) *Property { 662 | c := &ComboProperty{ 663 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 664 | } 665 | c.parent = c 666 | 667 | c.Values = make([]string, 0) 668 | for _, v := range input { 669 | if s, ok := v.(string); ok { 670 | c.Values = append(c.Values, s) 671 | } else if b, ok := v.(bool); ok { 672 | // combo is a bool 673 | c.IsBool = true 674 | if b { 675 | c.Values = append(c.Values, "true") 676 | } else { 677 | c.Values = append(c.Values, "false") 678 | } 679 | } else { 680 | slog.Debug(fmt.Sprintf("TODO - Potential non-string combo entry <%s>", reflect.TypeOf(v).Name())) 681 | } 682 | } 683 | var retv Property = c 684 | 685 | return &retv 686 | } 687 | 688 | func (p *ComboProperty) TypeString() string { 689 | return "COMBO" 690 | } 691 | 692 | func (p *ComboProperty) Optional() bool { 693 | return p.optional 694 | } 695 | 696 | func (p *ComboProperty) Settable() bool { 697 | return true 698 | } 699 | 700 | func (p *ComboProperty) Name() string { 701 | return p.name 702 | } 703 | 704 | func (p *ComboProperty) valueFromString(value string) interface{} { 705 | if p.IsBool { 706 | tl := strings.ToLower(value) 707 | if tl == "true" { 708 | return true 709 | } else { 710 | return false 711 | } 712 | } 713 | 714 | // ensure we have this string in our values 715 | for _, v := range p.Values { 716 | if value == v { 717 | return value 718 | } 719 | } 720 | return nil 721 | } 722 | 723 | // Append will add the new value to the combo if it's not already available, and then sets 724 | // the target property to the given value 725 | func (p *ComboProperty) Append(newValue string) { 726 | if p.IsBool { 727 | return 728 | } 729 | 730 | // do we already have this one? 731 | have := false 732 | for i := range p.Values { 733 | if p.Values[i] == newValue { 734 | // we have this 735 | have = true 736 | break 737 | } 738 | } 739 | if !have { 740 | p.Values = append(p.Values, newValue) 741 | } 742 | p.SetValue(newValue) 743 | } 744 | 745 | type ImageUploadProperty struct { 746 | BaseProperty 747 | TargetProperty *ComboProperty 748 | } 749 | 750 | func newImageUploadProperty(input_name string, target *ComboProperty, index int) *Property { 751 | c := &ImageUploadProperty{ 752 | BaseProperty: BaseProperty{name: input_name, optional: false, serializable: true, override_property: target.name, index: index, target_value_index: -1}, 753 | TargetProperty: target, 754 | } 755 | c.parent = c 756 | 757 | var retv Property = c 758 | return &retv 759 | } 760 | func (p *ImageUploadProperty) TypeString() string { 761 | return "IMAGEUPLOAD" 762 | } 763 | func (p *ImageUploadProperty) Optional() bool { 764 | return p.optional 765 | } 766 | func (p *ImageUploadProperty) Settable() bool { 767 | return false 768 | } 769 | func (p *ImageUploadProperty) Name() string { 770 | return p.name 771 | } 772 | func (p *ImageUploadProperty) SetFilename(filename string) { 773 | if p.TargetProperty != nil { 774 | p.TargetProperty.Append(filename) 775 | } 776 | } 777 | func (p *ImageUploadProperty) valueFromString(value string) interface{} { 778 | return nil 779 | } 780 | 781 | type UnknownProperty struct { 782 | BaseProperty 783 | TypeName string 784 | } 785 | 786 | func newUnknownProperty(input_name string, optional bool, typename string, index int) *Property { 787 | c := &UnknownProperty{ 788 | BaseProperty: BaseProperty{name: input_name, optional: optional, serializable: true, index: index, target_value_index: -1}, 789 | TypeName: typename, 790 | } 791 | c.parent = c 792 | 793 | var retv Property = c 794 | return &retv 795 | } 796 | func (p *UnknownProperty) TypeString() string { 797 | return p.TypeName 798 | } 799 | func (p *UnknownProperty) Optional() bool { 800 | return p.optional 801 | } 802 | func (p *UnknownProperty) Settable() bool { 803 | return false 804 | } 805 | func (p *UnknownProperty) Name() string { 806 | return p.name 807 | } 808 | func (p *UnknownProperty) valueFromString(value string) interface{} { 809 | return nil 810 | } 811 | 812 | func NewPropertyFromInput(input_name string, optional bool, input *interface{}, index int) *Property { 813 | // Convert the pointer back to an interface 814 | dereferenced := *input 815 | 816 | // Attempt to assert the interface as a slice of interfaces 817 | if slice, ok := dereferenced.([]interface{}); ok { 818 | // is it at least a size of 1? 819 | if len(slice) == 0 { 820 | return nil 821 | } 822 | 823 | // the first item is either an array of strings (a combo), or the property type 824 | if ptype, ok := slice[0].([]interface{}); ok { 825 | if !isCascadingProperty(ptype) { 826 | return newComboProperty(input_name, optional, ptype, index) 827 | } else { 828 | return newCascadeProperty(input_name, optional, ptype, index) 829 | } 830 | } else { 831 | if stype, ok := slice[0].(string); ok { 832 | // This will prevent panic: runtime error: index out of range [1] with length 1 833 | if len(slice) < 2 { 834 | return newUnknownProperty(input_name, optional, stype, index) 835 | } 836 | 837 | switch stype { 838 | case "STRING": 839 | return newStringProperty(input_name, optional, slice[1], index) 840 | case "INT": 841 | return newIntProperty(input_name, optional, slice[1], index) 842 | case "FLOAT": 843 | return newFloatProperty(input_name, optional, slice[1], index) 844 | case "BOOLEAN": 845 | return newBoolProperty(input_name, optional, stype, index) 846 | case "IMAGE": 847 | return newUnknownProperty(input_name, optional, stype, index) 848 | case "MASK:": 849 | return newUnknownProperty(input_name, optional, stype, index) 850 | default: 851 | return newUnknownProperty(input_name, optional, stype, index) 852 | } 853 | } 854 | } 855 | } else if s, ok := dereferenced.(string); ok { 856 | // Edge case for an "Any" input property 857 | if s == "*" { 858 | // "Any" 859 | return newUnknownProperty(input_name, optional, "*", index) 860 | } 861 | } 862 | return nil 863 | } 864 | --------------------------------------------------------------------------------