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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user