Files
ImaGen/internal/backend/workflow_template.go
mAi 8435817ce1 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.
2026-05-11 17:29:57 +02:00

157 lines
4.6 KiB
Go

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
}
}