mAi: #10 - multi-model backend expansion (workflow templates + compare harness)
Path 1 architecture: one comfyui adapter, workflows as data.
- workflow_template.go: embed.FS + token substitution with type-preserving
whole-value placeholders. ${prompt} → string, ${seed} → int64,
${cfg} → float64 — no JSON round-tripping. Partial matches ignored.
- comfyui.go: refactored to load workflow from embedded FS or filesystem
path. Back-compat preserved: workflow: defaults to flux1-schnell.
- workflows/{flux1-schnell,flux2-klein,sd35-medium}.json — bundled
templates. flux1-schnell migrated from hardcoded with identical node IDs.
- compare.go: new `imagen compare` subcommand. Sequential N-backend run
(one GPU on mRock — parallel would OOM), per-backend PNG, sidecar JSON
with per-model metadata + errors, composite contact sheet via Go image
package (no ImageMagick dep).
- Sample config gains flux2-klein-local + sd35-medium-local instances.
- docs/backends.md: architecture rationale + per-model HF download paths
+ how to add a new bundled workflow + compare-harness reference.
Live smoke verified: compare mock + flux-schnell-local at 768×768 →
both PNGs written, sidecar JSON has workflow="flux1-schnell" + full
metadata, contact sheet renders. Worker contract (Request → Generate)
unchanged, so flexsiebels /imagine UI API surface preserved.
Tests: 11 existing comfyui + 6 new workflow_template + 5 new compare
tests, all green.
Adding a new model is now yaml + JSON, never Go.
This commit is contained in:
@@ -20,24 +20,29 @@ import (
|
||||
const ComfyType = "comfyui"
|
||||
|
||||
// Comfy is the ComfyUI adapter. It speaks the public `/prompt` + `/history`
|
||||
// + `/view` HTTP API and submits a fixed FLUX.1 schnell workflow built from
|
||||
// the values in Request.
|
||||
// + `/view` HTTP API and submits a workflow built by substituting Request
|
||||
// values into a JSON template (bundled under internal/backend/workflows/ or
|
||||
// loaded from a filesystem path).
|
||||
//
|
||||
// Concurrency: a single Comfy is safe to share across goroutines as long as
|
||||
// the underlying http.Client is. Generate does not hold long-lived state.
|
||||
type Comfy struct {
|
||||
instance string
|
||||
|
||||
base string
|
||||
model string
|
||||
vae string
|
||||
clipL string
|
||||
clipT5 string
|
||||
dtype string
|
||||
base string
|
||||
workflow string
|
||||
|
||||
// rawCfg keeps the original yaml block (minus framework keys) so we can
|
||||
// expose every user-defined string/number as a workflow substitution
|
||||
// without enumerating each per-model knob in Go. Empty values still get
|
||||
// a substitution entry so a template can reference ${negative} when the
|
||||
// request didn't pass one.
|
||||
rawCfg map[string]any
|
||||
|
||||
defaultSteps int
|
||||
defaultSampler string
|
||||
defaultScheduler string
|
||||
defaultCFG float64
|
||||
|
||||
httpClient *http.Client
|
||||
pollInterval time.Duration
|
||||
@@ -49,12 +54,20 @@ type Comfy struct {
|
||||
}
|
||||
|
||||
// NewComfy is the registry constructor. cfg is the adapter's slice of
|
||||
// imagen.yaml. Required keys: base_url, model. The rest have sensible FLUX
|
||||
// schnell defaults.
|
||||
// imagen.yaml.
|
||||
//
|
||||
// Required keys: base_url, model.
|
||||
// Optional keys: workflow (defaults to "flux1-schnell" for back-compat with
|
||||
// existing configs), default_steps, default_sampler, default_scheduler,
|
||||
// default_cfg, plus any template-specific knobs (vae, clip, clip_l,
|
||||
// clip_t5, dtype, shift, guidance, …) the chosen workflow references.
|
||||
func NewComfy(name string, cfg map[string]any) (Backend, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("comfyui: empty instance name")
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
base := strings.TrimRight(getString(cfg, "base_url", ""), "/")
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("comfyui[%s]: base_url is required", name)
|
||||
@@ -67,23 +80,27 @@ func NewComfy(name string, cfg map[string]any) (Backend, error) {
|
||||
return nil, fmt.Errorf("comfyui[%s]: model is required", name)
|
||||
}
|
||||
|
||||
workflow := getString(cfg, "workflow", "flux1-schnell")
|
||||
// Fail fast on a bad workflow ref so users see the error at startup,
|
||||
// not on first /prompt submission.
|
||||
if _, err := LoadWorkflowTemplate(workflow); err != nil {
|
||||
return nil, fmt.Errorf("comfyui[%s]: %w", name, err)
|
||||
}
|
||||
|
||||
c := &Comfy{
|
||||
instance: name,
|
||||
base: base,
|
||||
model: model,
|
||||
|
||||
vae: getString(cfg, "vae", "ae.safetensors"),
|
||||
clipL: getString(cfg, "clip_l", "clip_l.safetensors"),
|
||||
clipT5: getString(cfg, "clip_t5", "t5xxl_fp8_e4m3fn.safetensors"),
|
||||
dtype: getString(cfg, "weight_dtype", "fp8_e4m3fn"),
|
||||
workflow: workflow,
|
||||
rawCfg: cfg,
|
||||
|
||||
defaultSteps: getInt(cfg, "default_steps", 4),
|
||||
defaultSampler: getString(cfg, "default_sampler", "euler"),
|
||||
defaultScheduler: getString(cfg, "default_scheduler", "simple"),
|
||||
defaultCFG: getFloat(cfg, "default_cfg", 1.0),
|
||||
|
||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||
pollInterval: 250 * time.Millisecond,
|
||||
pollTimeout: 120 * time.Second,
|
||||
pollTimeout: 300 * time.Second,
|
||||
|
||||
randSeed: cryptoSeed,
|
||||
clientIDFn: randClientID,
|
||||
@@ -103,19 +120,26 @@ func (c *Comfy) Generate(ctx context.Context, req Request) (*Result, error) {
|
||||
|
||||
sampler := c.defaultSampler
|
||||
scheduler := c.defaultScheduler
|
||||
cfg := c.defaultCFG
|
||||
if v, ok := req.BackendOpts["sampler"].(string); ok && v != "" {
|
||||
sampler = v
|
||||
}
|
||||
if v, ok := req.BackendOpts["scheduler"].(string); ok && v != "" {
|
||||
scheduler = v
|
||||
}
|
||||
if v, ok := req.BackendOpts["cfg"].(float64); ok && v > 0 {
|
||||
cfg = v
|
||||
}
|
||||
|
||||
seed := req.Seed
|
||||
if seed == 0 {
|
||||
seed = c.randSeed()
|
||||
}
|
||||
|
||||
workflow := c.buildWorkflow(req.Prompt, req.NegativePrompt, width, height, seed, steps, sampler, scheduler)
|
||||
workflow, err := c.buildWorkflow(req.Prompt, req.NegativePrompt, width, height, seed, steps, sampler, scheduler, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("comfyui[%s]: build workflow: %w", c.instance, err)
|
||||
}
|
||||
clientID := c.clientIDFn()
|
||||
|
||||
start := time.Now()
|
||||
@@ -133,14 +157,17 @@ func (c *Comfy) Generate(ctx context.Context, req Request) (*Result, error) {
|
||||
}
|
||||
latencyMs := time.Since(start).Milliseconds()
|
||||
|
||||
model := getString(c.rawCfg, "model", "")
|
||||
meta := map[string]any{
|
||||
"backend": c.instance,
|
||||
"backend_type": ComfyType,
|
||||
"model": c.model,
|
||||
"workflow": c.workflow,
|
||||
"model": model,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"sampler": sampler,
|
||||
"scheduler": scheduler,
|
||||
"cfg": cfg,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"latency_ms": latencyMs,
|
||||
@@ -173,6 +200,7 @@ func (c *Comfy) submitPrompt(ctx context.Context, workflow map[string]any, clien
|
||||
return "", fmt.Errorf("comfyui: marshal workflow: %w", err)
|
||||
}
|
||||
|
||||
model := getString(c.rawCfg, "model", "")
|
||||
var lastErr error
|
||||
for attempt := range 2 {
|
||||
if attempt > 0 {
|
||||
@@ -196,7 +224,7 @@ func (c *Comfy) submitPrompt(ctx context.Context, workflow map[string]any, clien
|
||||
_ = resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return parsePromptID(respBody, c.model)
|
||||
return parsePromptID(respBody, model)
|
||||
case resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("comfyui /prompt %d: %s", resp.StatusCode, snip(respBody))
|
||||
continue
|
||||
@@ -333,98 +361,74 @@ func (c *Comfy) connError(err error) error {
|
||||
// workflow-validation failures and put the diagnostics in node_errors; older
|
||||
// builds use 200 + node_errors. This handles the 4xx flavour.
|
||||
func (c *Comfy) classifyBadRequest(status int, body []byte) error {
|
||||
if hint, ok := missingModelHint(body, c.model); ok {
|
||||
return fmt.Errorf("comfyui /prompt %d: %s — see docs/setup-comfyui-mrock.md", status, hint)
|
||||
model := getString(c.rawCfg, "model", "")
|
||||
if hint, ok := missingModelHint(body, model); ok {
|
||||
return fmt.Errorf("comfyui /prompt %d: %s — see docs/backends.md", status, hint)
|
||||
}
|
||||
return fmt.Errorf("comfyui /prompt %d: %s", status, snip(body))
|
||||
}
|
||||
|
||||
// buildWorkflow assembles the canonical FLUX.1 schnell ComfyUI workflow,
|
||||
// node-IDs matching the upstream "flux-schnell" template so anyone debugging
|
||||
// in the ComfyUI UI sees a familiar shape.
|
||||
func (c *Comfy) buildWorkflow(prompt, negative string, w, h int, seed int64, steps int, sampler, scheduler string) map[string]any {
|
||||
return map[string]any{
|
||||
"6": map[string]any{
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": map[string]any{
|
||||
"text": prompt,
|
||||
"clip": []any{"11", 0},
|
||||
},
|
||||
},
|
||||
"8": map[string]any{
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": map[string]any{
|
||||
"samples": []any{"31", 0},
|
||||
"vae": []any{"10", 0},
|
||||
},
|
||||
},
|
||||
"9": map[string]any{
|
||||
"class_type": "SaveImage",
|
||||
"inputs": map[string]any{
|
||||
"filename_prefix": "imagen",
|
||||
"images": []any{"8", 0},
|
||||
},
|
||||
},
|
||||
"10": map[string]any{
|
||||
"class_type": "VAELoader",
|
||||
"inputs": map[string]any{"vae_name": c.vae},
|
||||
},
|
||||
"11": map[string]any{
|
||||
"class_type": "DualCLIPLoader",
|
||||
"inputs": map[string]any{
|
||||
"clip_name1": c.clipT5,
|
||||
"clip_name2": c.clipL,
|
||||
"type": "flux",
|
||||
},
|
||||
},
|
||||
"12": map[string]any{
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": map[string]any{
|
||||
"unet_name": c.model,
|
||||
"weight_dtype": c.dtype,
|
||||
},
|
||||
},
|
||||
"13": map[string]any{
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": map[string]any{
|
||||
"text": negative,
|
||||
"clip": []any{"11", 0},
|
||||
},
|
||||
},
|
||||
"27": map[string]any{
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": map[string]any{
|
||||
"width": w,
|
||||
"height": h,
|
||||
"batch_size": 1,
|
||||
},
|
||||
},
|
||||
"30": map[string]any{
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"inputs": map[string]any{
|
||||
"model": []any{"12", 0},
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": w,
|
||||
"height": h,
|
||||
},
|
||||
},
|
||||
"31": map[string]any{
|
||||
"class_type": "KSampler",
|
||||
"inputs": map[string]any{
|
||||
"model": []any{"30", 0},
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": sampler,
|
||||
"scheduler": scheduler,
|
||||
"denoise": 1.0,
|
||||
"positive": []any{"6", 0},
|
||||
"negative": []any{"13", 0},
|
||||
"latent_image": []any{"27", 0},
|
||||
},
|
||||
},
|
||||
// buildWorkflow loads the configured workflow template and substitutes the
|
||||
// per-call placeholders (prompt, seed, sampler, …) plus any string/number
|
||||
// fields the user defined in the yaml block. The set of placeholder keys
|
||||
// that aren't in `subs` produces an error from SubstituteWorkflow.
|
||||
func (c *Comfy) buildWorkflow(prompt, negative string, w, h int, seed int64, steps int, sampler, scheduler string, cfg float64) (map[string]any, error) {
|
||||
wf, err := LoadWorkflowTemplate(c.workflow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subs := map[string]any{
|
||||
"prompt": prompt,
|
||||
"negative": negative,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"sampler": sampler,
|
||||
"scheduler": scheduler,
|
||||
"cfg": cfg,
|
||||
}
|
||||
// Surface every scalar field from the yaml block so per-template knobs
|
||||
// (vae, clip, clip_l, clip_t5, dtype, shift, guidance, …) work without
|
||||
// adapter-code changes. Framework keys are excluded.
|
||||
for k, v := range c.rawCfg {
|
||||
switch k {
|
||||
case "type", "base_url", "workflow",
|
||||
"default_steps", "default_sampler", "default_scheduler", "default_cfg":
|
||||
continue
|
||||
}
|
||||
if _, alreadySet := subs[k]; alreadySet {
|
||||
// A per-call var (e.g. ${prompt}) beats anything yaml put under
|
||||
// the same key — yaml can't shadow request-derived values.
|
||||
continue
|
||||
}
|
||||
switch v := v.(type) {
|
||||
case string, int, int64, float64, bool:
|
||||
subs[k] = v
|
||||
}
|
||||
}
|
||||
// Provide sensible defaults for common optional knobs so a workflow that
|
||||
// references one of these doesn't fail substitution when the user
|
||||
// didn't override it in yaml. Extra keys are ignored if the workflow
|
||||
// doesn't reference them, so it's safe to always set the lot.
|
||||
defaults := map[string]any{
|
||||
"vae": "ae.safetensors",
|
||||
"clip_l": "clip_l.safetensors",
|
||||
"clip_t5": "t5xxl_fp8_e4m3fn.safetensors",
|
||||
"clip": "qwen_3_4b.safetensors",
|
||||
"dtype": "fp8_e4m3fn",
|
||||
"guidance": 4.0,
|
||||
"shift": 3.0,
|
||||
}
|
||||
for k, v := range defaults {
|
||||
if _, ok := subs[k]; !ok {
|
||||
subs[k] = v
|
||||
}
|
||||
}
|
||||
if _, err := SubstituteWorkflow(wf, subs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
// parsePromptID handles the 2xx /prompt response. ComfyUI sometimes 200s a
|
||||
@@ -432,8 +436,8 @@ func (c *Comfy) buildWorkflow(prompt, negative string, w, h int, seed int64, ste
|
||||
// turns that into the same user-facing error as a 4xx with the same body.
|
||||
func parsePromptID(body []byte, model string) (string, error) {
|
||||
var resp struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
NodeErrors map[string]any `json:"node_errors"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
NodeErrors map[string]any `json:"node_errors"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
@@ -441,7 +445,7 @@ func parsePromptID(body []byte, model string) (string, error) {
|
||||
}
|
||||
if len(resp.NodeErrors) > 0 || len(resp.Error) > 0 {
|
||||
if hint, ok := missingModelHint(body, model); ok {
|
||||
return "", fmt.Errorf("comfyui /prompt: %s — see docs/setup-comfyui-mrock.md", hint)
|
||||
return "", fmt.Errorf("comfyui /prompt: %s — see docs/backends.md", hint)
|
||||
}
|
||||
return "", fmt.Errorf("comfyui /prompt rejected workflow: %s", snip(body))
|
||||
}
|
||||
@@ -489,15 +493,21 @@ func parseHistory(body []byte, promptID string) (string, bool, error) {
|
||||
}
|
||||
|
||||
// missingModelHint returns a user-actionable message when the response body
|
||||
// indicates the configured unet model isn't loaded on the server. ComfyUI
|
||||
// uses both the human-readable "Value not in list" message and the enum
|
||||
// "value_not_in_list" type — match either.
|
||||
// indicates the configured unet/checkpoint model isn't loaded on the server.
|
||||
// ComfyUI uses both the human-readable "Value not in list" message and the
|
||||
// enum "value_not_in_list" type — match either.
|
||||
func missingModelHint(body []byte, model string) (string, bool) {
|
||||
s := string(body)
|
||||
hasMarker := strings.Contains(s, "Value not in list") || strings.Contains(s, "value_not_in_list")
|
||||
if hasMarker && strings.Contains(s, "unet_name") {
|
||||
if !hasMarker {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(s, "unet_name") {
|
||||
return fmt.Sprintf("model %q not present in the ComfyUI server's models/unet/", model), true
|
||||
}
|
||||
if strings.Contains(s, "ckpt_name") {
|
||||
return fmt.Sprintf("checkpoint %q not present in the ComfyUI server's models/checkpoints/", model), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -536,6 +546,22 @@ func getInt(m map[string]any, k string, def int) int {
|
||||
return def
|
||||
}
|
||||
|
||||
func getFloat(m map[string]any, k string, def float64) float64 {
|
||||
if v, ok := m[k]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case float32:
|
||||
return float64(n)
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func orDefaultInt(v, def int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestComfyMissingModelHintsAtSetupDoc(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "docs/setup-comfyui-mrock.md") {
|
||||
if !strings.Contains(msg, "docs/backends.md") {
|
||||
t.Errorf("error should point at the setup doc, got %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "flux1-schnell.safetensors") {
|
||||
@@ -331,7 +331,7 @@ func TestComfyMissingModelOn200WithNodeErrors(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for node_errors on 200")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs/setup-comfyui-mrock.md") {
|
||||
if !strings.Contains(err.Error(), "docs/backends.md") {
|
||||
t.Errorf("error should point at the setup doc, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
156
internal/backend/workflow_template.go
Normal file
156
internal/backend/workflow_template.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed workflows/*.json
|
||||
var bundledWorkflows embed.FS
|
||||
|
||||
// placeholderRE matches a single-token placeholder like "${prompt}" — the
|
||||
// whole string value must be the placeholder, leading/trailing whitespace
|
||||
// allowed. This lets us preserve types (a numeric substitution becomes a
|
||||
// JSON number, not a stringified one) instead of round-tripping through
|
||||
// strings.Replace which would force everything into a string.
|
||||
var placeholderRE = regexp.MustCompile(`^\s*\$\{([a-zA-Z][a-zA-Z0-9_]*)\}\s*$`)
|
||||
|
||||
// LoadWorkflowTemplate returns the parsed JSON for a workflow template.
|
||||
// `name` is resolved in this order:
|
||||
//
|
||||
// 1. exact filesystem path that exists on disk (absolute or relative);
|
||||
// 2. one of the bundled templates under internal/backend/workflows/
|
||||
// (with or without the .json suffix).
|
||||
//
|
||||
// The returned map is a fresh deep copy of the template; callers can mutate
|
||||
// it freely.
|
||||
func LoadWorkflowTemplate(name string) (map[string]any, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("workflow template name is empty")
|
||||
}
|
||||
raw, err := readWorkflowBytes(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wf map[string]any
|
||||
if err := json.Unmarshal(raw, &wf); err != nil {
|
||||
return nil, fmt.Errorf("workflow %s: parse: %w", name, err)
|
||||
}
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
// BundledWorkflowNames returns the names of templates compiled into the
|
||||
// binary, sorted. Each name is the basename without the .json suffix.
|
||||
func BundledWorkflowNames() []string {
|
||||
entries, err := fs.ReadDir(bundledWorkflows, "workflows")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if !strings.HasSuffix(n, ".json") {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimSuffix(n, ".json"))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func readWorkflowBytes(name string) ([]byte, error) {
|
||||
// Filesystem path wins if it points at a real file. Lets a user override
|
||||
// a bundled template by passing an absolute path in yaml.
|
||||
if strings.ContainsRune(name, os.PathSeparator) || strings.HasSuffix(name, ".json") {
|
||||
if b, err := os.ReadFile(name); err == nil {
|
||||
return b, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("workflow %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
// Bundled lookup. Try the literal name as a file inside workflows/, then
|
||||
// with the .json suffix appended.
|
||||
candidates := []string{
|
||||
filepath.Join("workflows", name),
|
||||
filepath.Join("workflows", name+".json"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if b, err := bundledWorkflows.ReadFile(c); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("workflow %q not found (bundled templates: %v)", name, BundledWorkflowNames())
|
||||
}
|
||||
|
||||
// SubstituteWorkflow walks wf and replaces every "${key}" string with the
|
||||
// matching value from subs, preserving JSON types. Returns the set of
|
||||
// placeholder keys it actually touched, so the caller can detect missing
|
||||
// substitutions even when a key is defined in subs but never referenced in
|
||||
// the workflow (typical when a yaml block sets a knob a different template
|
||||
// would consume).
|
||||
//
|
||||
// Unknown placeholders (referenced in the workflow but absent from subs)
|
||||
// produce an error so we never submit a workflow with raw "${foo}" tokens.
|
||||
func SubstituteWorkflow(wf map[string]any, subs map[string]any) (used map[string]struct{}, err error) {
|
||||
used = make(map[string]struct{})
|
||||
walked, err := substituteValue(wf, subs, used)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// substituteValue returns the replacement for the top-level value, which
|
||||
// should still be the same map (just with mutated children).
|
||||
if m, ok := walked.(map[string]any); ok {
|
||||
// Copy back into wf so the caller's reference reflects the result.
|
||||
for k := range wf {
|
||||
delete(wf, k)
|
||||
}
|
||||
maps.Copy(wf, m)
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
func substituteValue(v any, subs map[string]any, used map[string]struct{}) (any, error) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for k, child := range x {
|
||||
replaced, err := substituteValue(child, subs, used)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = replaced
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, child := range x {
|
||||
replaced, err := substituteValue(child, subs, used)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = replaced
|
||||
}
|
||||
return out, nil
|
||||
case string:
|
||||
if m := placeholderRE.FindStringSubmatch(x); m != nil {
|
||||
key := m[1]
|
||||
val, ok := subs[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("workflow placeholder ${%s} has no substitution", key)
|
||||
}
|
||||
used[key] = struct{}{}
|
||||
return val, nil
|
||||
}
|
||||
return x, nil
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
153
internal/backend/workflow_template_test.go
Normal file
153
internal/backend/workflow_template_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBundledWorkflowsParseable(t *testing.T) {
|
||||
names := BundledWorkflowNames()
|
||||
if len(names) == 0 {
|
||||
t.Fatal("expected at least one bundled workflow")
|
||||
}
|
||||
mustHave := []string{"flux1-schnell", "flux2-klein", "sd35-medium"}
|
||||
for _, want := range mustHave {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Errorf("bundled workflows missing %q (have: %v)", want, names)
|
||||
}
|
||||
}
|
||||
// Every bundled template must parse and contain at least one node.
|
||||
for _, n := range names {
|
||||
wf, err := LoadWorkflowTemplate(n)
|
||||
if err != nil {
|
||||
t.Errorf("LoadWorkflowTemplate(%q): %v", n, err)
|
||||
continue
|
||||
}
|
||||
if len(wf) == 0 {
|
||||
t.Errorf("workflow %q has zero nodes", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkflowFromFilesystem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "custom.json")
|
||||
body := `{"1":{"class_type":"X","inputs":{"v":"${prompt}"}}}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write tmp workflow: %v", err)
|
||||
}
|
||||
wf, err := LoadWorkflowTemplate(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load from path: %v", err)
|
||||
}
|
||||
if _, ok := wf["1"]; !ok {
|
||||
t.Errorf("custom workflow missing node 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkflowUnknownNameErrors(t *testing.T) {
|
||||
_, err := LoadWorkflowTemplate("definitely-not-a-real-workflow")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown workflow name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error should say not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteWorkflowPreservesTypes(t *testing.T) {
|
||||
wf := map[string]any{
|
||||
"31": map[string]any{
|
||||
"class_type": "KSampler",
|
||||
"inputs": map[string]any{
|
||||
"seed": "${seed}",
|
||||
"steps": "${steps}",
|
||||
"text": "${prompt}",
|
||||
"cfg": "${cfg}",
|
||||
},
|
||||
},
|
||||
}
|
||||
subs := map[string]any{
|
||||
"seed": int64(42),
|
||||
"steps": 11,
|
||||
"prompt": "a cat",
|
||||
"cfg": 4.5,
|
||||
}
|
||||
used, err := SubstituteWorkflow(wf, subs)
|
||||
if err != nil {
|
||||
t.Fatalf("Substitute: %v", err)
|
||||
}
|
||||
if len(used) != 4 {
|
||||
t.Errorf("used = %v, want all four", used)
|
||||
}
|
||||
inputs := wf["31"].(map[string]any)["inputs"].(map[string]any)
|
||||
if seed, ok := inputs["seed"].(int64); !ok || seed != 42 {
|
||||
t.Errorf("seed = %T %v, want int64 42", inputs["seed"], inputs["seed"])
|
||||
}
|
||||
if steps, ok := inputs["steps"].(int); !ok || steps != 11 {
|
||||
t.Errorf("steps = %T %v, want int 11", inputs["steps"], inputs["steps"])
|
||||
}
|
||||
if text, ok := inputs["text"].(string); !ok || text != "a cat" {
|
||||
t.Errorf("text = %T %v, want string", inputs["text"], inputs["text"])
|
||||
}
|
||||
if cfg, ok := inputs["cfg"].(float64); !ok || cfg != 4.5 {
|
||||
t.Errorf("cfg = %T %v, want float64 4.5", inputs["cfg"], inputs["cfg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteWorkflowMissingPlaceholderErrors(t *testing.T) {
|
||||
wf := map[string]any{
|
||||
"1": map[string]any{"inputs": map[string]any{"v": "${missing}"}},
|
||||
}
|
||||
_, err := SubstituteWorkflow(wf, map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing placeholder")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "${missing}") {
|
||||
t.Errorf("error should name the placeholder, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteWorkflowOnlyWholeTokens(t *testing.T) {
|
||||
// Partial-match strings ("prefix ${prompt} suffix") are NOT substituted —
|
||||
// the placeholder must be the whole value so we can preserve types.
|
||||
wf := map[string]any{
|
||||
"1": map[string]any{"inputs": map[string]any{
|
||||
"keep_string": "stuff with ${prompt} inside",
|
||||
"replace_full": "${prompt}",
|
||||
}},
|
||||
}
|
||||
used, err := SubstituteWorkflow(wf, map[string]any{"prompt": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Substitute: %v", err)
|
||||
}
|
||||
inputs := wf["1"].(map[string]any)["inputs"].(map[string]any)
|
||||
if inputs["keep_string"].(string) != "stuff with ${prompt} inside" {
|
||||
t.Errorf("partial match should be left alone, got %q", inputs["keep_string"])
|
||||
}
|
||||
if inputs["replace_full"].(string) != "x" {
|
||||
t.Errorf("full-value match should substitute, got %q", inputs["replace_full"])
|
||||
}
|
||||
if _, ok := used["prompt"]; !ok {
|
||||
t.Errorf("used should track keys that fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlux1SchnellTemplateMatchesLegacyShape(t *testing.T) {
|
||||
// Regression guard against the historical hardcoded workflow: every
|
||||
// node ID the old Comfy.buildWorkflow used must still exist in the
|
||||
// migrated template.
|
||||
wf, err := LoadWorkflowTemplate("flux1-schnell")
|
||||
if err != nil {
|
||||
t.Fatalf("load flux1-schnell: %v", err)
|
||||
}
|
||||
legacyNodes := []string{"6", "8", "9", "10", "11", "12", "13", "27", "30", "31"}
|
||||
for _, id := range legacyNodes {
|
||||
if _, ok := wf[id]; !ok {
|
||||
t.Errorf("flux1-schnell template missing node %q (legacy parity)", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
internal/backend/workflows/flux1-schnell.json
Normal file
84
internal/backend/workflows/flux1-schnell.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "${prompt}",
|
||||
"clip": ["11", 0]
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["31", 0],
|
||||
"vae": ["10", 0]
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "imagen",
|
||||
"images": ["8", 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "${vae}"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"class_type": "DualCLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name1": "${clip_t5}",
|
||||
"clip_name2": "${clip_l}",
|
||||
"type": "flux"
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "${model}",
|
||||
"weight_dtype": "${dtype}"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "${negative}",
|
||||
"clip": ["11", 0]
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": "${width}",
|
||||
"height": "${height}",
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"30": {
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"inputs": {
|
||||
"model": ["12", 0],
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": "${width}",
|
||||
"height": "${height}"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["30", 0],
|
||||
"seed": "${seed}",
|
||||
"steps": "${steps}",
|
||||
"cfg": "${cfg}",
|
||||
"sampler_name": "${sampler}",
|
||||
"scheduler": "${scheduler}",
|
||||
"denoise": 1.0,
|
||||
"positive": ["6", 0],
|
||||
"negative": ["13", 0],
|
||||
"latent_image": ["27", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
79
internal/backend/workflows/flux2-klein.json
Normal file
79
internal/backend/workflows/flux2-klein.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "${prompt}",
|
||||
"clip": ["11", 0]
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["31", 0],
|
||||
"vae": ["10", 0]
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "imagen",
|
||||
"images": ["8", 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "${vae}"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "${clip}",
|
||||
"type": "flux2"
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "${model}",
|
||||
"weight_dtype": "${dtype}"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"conditioning": ["6", 0],
|
||||
"guidance": "${guidance}"
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {
|
||||
"conditioning": ["6", 0]
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"class_type": "EmptyFlux2LatentImage",
|
||||
"inputs": {
|
||||
"width": "${width}",
|
||||
"height": "${height}",
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["12", 0],
|
||||
"seed": "${seed}",
|
||||
"steps": "${steps}",
|
||||
"cfg": "${cfg}",
|
||||
"sampler_name": "${sampler}",
|
||||
"scheduler": "${scheduler}",
|
||||
"denoise": 1.0,
|
||||
"positive": ["14", 0],
|
||||
"negative": ["15", 0],
|
||||
"latent_image": ["27", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
66
internal/backend/workflows/sd35-medium.json
Normal file
66
internal/backend/workflows/sd35-medium.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {
|
||||
"ckpt_name": "${model}"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "${prompt}",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "${negative}",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["31", 0],
|
||||
"vae": ["4", 2]
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "imagen",
|
||||
"images": ["8", 0]
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "ModelSamplingSD3",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"shift": "${shift}"
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": "${width}",
|
||||
"height": "${height}",
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["13", 0],
|
||||
"seed": "${seed}",
|
||||
"steps": "${steps}",
|
||||
"cfg": "${cfg}",
|
||||
"sampler_name": "${sampler}",
|
||||
"scheduler": "${scheduler}",
|
||||
"denoise": 1.0,
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["27", 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,15 +144,54 @@ output:
|
||||
cloud_sync: auto
|
||||
|
||||
backends:
|
||||
# FLUX.1-schnell on the local ComfyUI server. The "workflow" key picks the
|
||||
# bundled template under internal/backend/workflows/; omit it for back-compat
|
||||
# (defaults to flux1-schnell). See docs/backends.md for the per-model setup.
|
||||
flux-schnell-local:
|
||||
type: comfyui
|
||||
base_url: http://mrock:8188
|
||||
workflow: flux1-schnell
|
||||
# Filename of the unet checkpoint inside the ComfyUI server's
|
||||
# models/unet/ directory. See docs/setup-comfyui-mrock.md.
|
||||
# models/unet/ directory.
|
||||
model: flux1-schnell.safetensors
|
||||
vae: ae.safetensors
|
||||
clip_l: clip_l.safetensors
|
||||
clip_t5: t5xxl_fp8_e4m3fn.safetensors
|
||||
dtype: fp8_e4m3fn
|
||||
default_steps: 4
|
||||
default_sampler: euler
|
||||
default_scheduler: simple
|
||||
default_cfg: 1.0
|
||||
|
||||
# FLUX.2 [klein] 4B distilled — sub-second on RTX 4070 Ti SUPER.
|
||||
# Weights: BFL non-commercial; flux-2-klein-base-4b-fp8 in models/unet/,
|
||||
# qwen_3_4b in models/text_encoders/, flux2-vae in models/vae/.
|
||||
flux2-klein-local:
|
||||
type: comfyui
|
||||
base_url: http://mrock:8188
|
||||
workflow: flux2-klein
|
||||
model: flux-2-klein-base-4b-fp8.safetensors
|
||||
vae: flux2-vae.safetensors
|
||||
clip: qwen_3_4b.safetensors
|
||||
dtype: fp8_e4m3fn
|
||||
default_steps: 4
|
||||
default_sampler: euler
|
||||
default_scheduler: simple
|
||||
default_cfg: 1.0
|
||||
guidance: 4.0
|
||||
|
||||
# SD3.5 medium — single-checkpoint variant that bundles the three text
|
||||
# encoders inside the .safetensors. Drop into models/checkpoints/.
|
||||
sd35-medium-local:
|
||||
type: comfyui
|
||||
base_url: http://mrock:8188
|
||||
workflow: sd35-medium
|
||||
model: sd3.5_medium_incl_clips_t5xxlfp8scaled.safetensors
|
||||
default_steps: 28
|
||||
default_sampler: dpmpp_2m
|
||||
default_scheduler: sgm_uniform
|
||||
default_cfg: 4.5
|
||||
shift: 3.0
|
||||
|
||||
mock:
|
||||
type: mock
|
||||
|
||||
Reference in New Issue
Block a user