G2: the gate keeps a websocket open to its ComfyUI under a client id of its
own, puts that id on every workflow it lets through — ComfyUI tells only
the client that queued a workflow how it goes — and keeps the last word on
each: the node running, its step of how many. It answers at
/progress/{prompt_id}. While a job's request waits, the router reads the
queue (queued, n ahead) and the gate (running, node, step, percent) and
writes it into the job, which a client reads back. Only into a job of the
same key: a tag naming another's job writes nothing.
G3, the rest of the plumbing: comfyui.nodes names custom nodes by https
repository and full commit, with the PyPI packages they need. The gate puts
each in the replica's own custom_nodes at that commit and installs the
packages beside the replica, constrained to the versions ComfyUI's
environment already has, which it never changes. The catalog refuses a
branch for a commit and anything but package specs for requirements. An
OBJ a node saves leaves the gateway as a GLB, colours kept, so every mesh
can carry its licence.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
package router
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// objToGLB turns a Wavefront OBJ — what a 3D node saves when it saves no
|
|
// GLB — into a glTF binary: one mesh of its vertices, their colours where
|
|
// the file gives them (TripoSR's do, after each position), and its faces
|
|
// as triangles. A GLB is the one file a game engine, a browser and the
|
|
// licence notice all take, so every mesh leaves the gateway as one.
|
|
func objToGLB(obj []byte) ([]byte, error) {
|
|
var pos, col []float32
|
|
var idx []uint32
|
|
colours := true
|
|
sc := bufio.NewScanner(bytes.NewReader(obj))
|
|
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
|
for sc.Scan() {
|
|
f := strings.Fields(sc.Text())
|
|
if len(f) == 0 {
|
|
continue
|
|
}
|
|
switch f[0] {
|
|
case "v":
|
|
if len(f) < 4 {
|
|
return nil, errors.New("a vertex with fewer than three coordinates")
|
|
}
|
|
for _, s := range f[1:4] {
|
|
v, err := strconv.ParseFloat(s, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("a vertex coordinate %q: %w", s, err)
|
|
}
|
|
pos = append(pos, float32(v))
|
|
}
|
|
if len(f) >= 7 {
|
|
for _, s := range f[4:7] {
|
|
v, err := strconv.ParseFloat(s, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("a vertex colour %q: %w", s, err)
|
|
}
|
|
col = append(col, float32(v))
|
|
}
|
|
} else {
|
|
colours = false
|
|
}
|
|
case "f":
|
|
n := uint32(len(pos) / 3) //nolint:gosec // bounded by the file read
|
|
var face []uint32
|
|
for _, s := range f[1:] {
|
|
ref, _, _ := strings.Cut(s, "/")
|
|
i, err := strconv.Atoi(ref)
|
|
if err != nil || i == 0 {
|
|
return nil, fmt.Errorf("a face index %q", s)
|
|
}
|
|
if i < 0 {
|
|
i = int(n) + i + 1
|
|
}
|
|
if i < 1 || uint32(i) > n { //nolint:gosec // checked positive
|
|
return nil, fmt.Errorf("a face index %d past the %d vertices", i, n)
|
|
}
|
|
face = append(face, uint32(i-1)) //nolint:gosec // checked positive
|
|
}
|
|
// A polygon as a fan of triangles.
|
|
for k := 1; k+1 < len(face); k++ {
|
|
idx = append(idx, face[0], face[k], face[k+1])
|
|
}
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(pos) == 0 || len(idx) == 0 {
|
|
return nil, errors.New("the OBJ has no faces")
|
|
}
|
|
if !colours || len(col) != len(pos) {
|
|
col = nil
|
|
}
|
|
|
|
var bin bytes.Buffer
|
|
view := func(data any) (offset, length int) {
|
|
offset = bin.Len()
|
|
_ = binary.Write(&bin, binary.LittleEndian, data)
|
|
length = bin.Len() - offset
|
|
for bin.Len()%4 != 0 {
|
|
bin.WriteByte(0)
|
|
}
|
|
return offset, length
|
|
}
|
|
lo := []float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
|
|
hi := []float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
|
|
for i, v := range pos {
|
|
lo[i%3], hi[i%3] = min(lo[i%3], v), max(hi[i%3], v)
|
|
}
|
|
po, pl := view(pos)
|
|
io, il := view(idx)
|
|
views := []map[string]any{
|
|
{"buffer": 0, "byteOffset": po, "byteLength": pl, "target": 34962},
|
|
{"buffer": 0, "byteOffset": io, "byteLength": il, "target": 34963},
|
|
}
|
|
accessors := []map[string]any{
|
|
{"bufferView": 0, "componentType": 5126, "count": len(pos) / 3, "type": "VEC3", "min": lo, "max": hi},
|
|
{"bufferView": 1, "componentType": 5125, "count": len(idx), "type": "SCALAR"},
|
|
}
|
|
attrs := map[string]any{"POSITION": 0}
|
|
if col != nil {
|
|
co, cl := view(col)
|
|
views = append(views, map[string]any{"buffer": 0, "byteOffset": co, "byteLength": cl, "target": 34962})
|
|
accessors = append(accessors, map[string]any{"bufferView": 2, "componentType": 5126, "count": len(col) / 3, "type": "VEC3"})
|
|
attrs["COLOR_0"] = 2
|
|
}
|
|
doc := map[string]any{
|
|
"asset": map[string]any{"version": "2.0", "generator": "llmgw"},
|
|
"scene": 0,
|
|
"scenes": []any{map[string]any{"nodes": []int{0}}},
|
|
"nodes": []any{map[string]any{"mesh": 0}},
|
|
"meshes": []any{map[string]any{"primitives": []any{map[string]any{"attributes": attrs, "indices": 1}}}},
|
|
"accessors": accessors,
|
|
"bufferViews": views,
|
|
"buffers": []any{map[string]any{"byteLength": bin.Len()}},
|
|
}
|
|
js, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for len(js)%4 != 0 {
|
|
js = append(js, ' ')
|
|
}
|
|
out := make([]byte, 0, 28+len(js)+bin.Len())
|
|
out = append(out, "glTF"...)
|
|
out = binary.LittleEndian.AppendUint32(out, 2)
|
|
out = binary.LittleEndian.AppendUint32(out, uint32(28+len(js)+bin.Len())) //nolint:gosec // bounded by the file read
|
|
out = binary.LittleEndian.AppendUint32(out, uint32(len(js))) //nolint:gosec // bounded by the file read
|
|
out = append(out, "JSON"...)
|
|
out = append(out, js...)
|
|
out = binary.LittleEndian.AppendUint32(out, uint32(bin.Len())) //nolint:gosec // bounded by the file read
|
|
out = append(out, "BIN\x00"...)
|
|
return append(out, bin.Bytes()...), nil
|
|
}
|