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.
204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// runCompareWithEnv runs the compare subcommand in a writable tmpdir, with
|
|
// XDG_CONFIG_HOME pointing somewhere empty so no host imagen.yaml leaks in.
|
|
func runCompareWithEnv(t *testing.T, args []string) (stderr, stdout *bytes.Buffer, runDir string, err error) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "no-config"))
|
|
t.Setenv("HOME", tmp)
|
|
|
|
out := filepath.Join(tmp, "compare")
|
|
// stdlib flag parsing requires flags after the leading positional. Append
|
|
// --output at the end so any caller-supplied flags still parse cleanly.
|
|
args = append(args, "--output", out)
|
|
|
|
// Capture stdout/stderr via os pipes since runCompare writes directly.
|
|
oldStdout := os.Stdout
|
|
oldStderr := os.Stderr
|
|
rOut, wOut, _ := os.Pipe()
|
|
rErr, wErr, _ := os.Pipe()
|
|
os.Stdout = wOut
|
|
os.Stderr = wErr
|
|
defer func() {
|
|
os.Stdout = oldStdout
|
|
os.Stderr = oldStderr
|
|
}()
|
|
|
|
cmdErr := runCompare(context.Background(), args)
|
|
|
|
_ = wOut.Close()
|
|
_ = wErr.Close()
|
|
stdout = &bytes.Buffer{}
|
|
stderr = &bytes.Buffer{}
|
|
_, _ = stdout.ReadFrom(rOut)
|
|
_, _ = stderr.ReadFrom(rErr)
|
|
|
|
entries, _ := os.ReadDir(out)
|
|
if len(entries) == 1 {
|
|
runDir = filepath.Join(out, entries[0].Name())
|
|
}
|
|
return stderr, stdout, runDir, cmdErr
|
|
}
|
|
|
|
func TestCompareHappyPathWithMockBackends(t *testing.T) {
|
|
// Two mock instances stand in for two different backends. mock ignores
|
|
// cfg so we can reuse the registered type as the instance name and skip
|
|
// writing imagen.yaml entirely.
|
|
stderr, stdout, runDir, err := runCompareWithEnv(t, []string{
|
|
"a cat in a fishbowl",
|
|
"--models", "mock,mock",
|
|
"--size", "64x64",
|
|
"--seed", "42",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("runCompare: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
|
|
if runDir == "" {
|
|
t.Fatal("expected a run directory under --output")
|
|
}
|
|
// Sidecar JSON
|
|
sidecar := filepath.Join(runDir, "compare.json")
|
|
data, err := os.ReadFile(sidecar)
|
|
if err != nil {
|
|
t.Fatalf("read sidecar: %v", err)
|
|
}
|
|
var body struct {
|
|
Prompt string `json:"prompt"`
|
|
Successful int `json:"successful"`
|
|
Total int `json:"total"`
|
|
Results []struct {
|
|
Backend string `json:"backend"`
|
|
ImagePath string `json:"image_path"`
|
|
Error string `json:"error"`
|
|
} `json:"results"`
|
|
}
|
|
if err := json.Unmarshal(data, &body); err != nil {
|
|
t.Fatalf("parse sidecar: %v\n%s", err, data)
|
|
}
|
|
if body.Prompt != "a cat in a fishbowl" {
|
|
t.Errorf("prompt = %q", body.Prompt)
|
|
}
|
|
if body.Total != 2 || body.Successful != 2 {
|
|
t.Errorf("counts = %d successful / %d total", body.Successful, body.Total)
|
|
}
|
|
for _, r := range body.Results {
|
|
if r.Error != "" {
|
|
t.Errorf("backend %s errored: %s", r.Backend, r.Error)
|
|
}
|
|
if _, err := os.Stat(r.ImagePath); err != nil {
|
|
t.Errorf("image not on disk for %s: %v", r.Backend, err)
|
|
}
|
|
}
|
|
|
|
// Contact sheet path was printed on stdout.
|
|
sheet := strings.TrimSpace(stdout.String())
|
|
if sheet == "" {
|
|
t.Fatal("expected contact sheet path on stdout")
|
|
}
|
|
f, err := os.Open(sheet)
|
|
if err != nil {
|
|
t.Fatalf("open contact sheet: %v", err)
|
|
}
|
|
defer f.Close()
|
|
img, err := png.Decode(f)
|
|
if err != nil {
|
|
t.Fatalf("decode contact sheet PNG: %v", err)
|
|
}
|
|
if w := img.Bounds().Dx(); w < 100 {
|
|
t.Errorf("contact sheet looks empty (width %d)", w)
|
|
}
|
|
}
|
|
|
|
func TestCompareSkipContactSheet(t *testing.T) {
|
|
stderr, stdout, runDir, err := runCompareWithEnv(t, []string{
|
|
"x",
|
|
"--models", "mock",
|
|
"--size", "32x32",
|
|
"--seed", "1",
|
|
"--no-contact-sheet",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("runCompare: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
if got := strings.TrimSpace(stdout.String()); got != "" {
|
|
t.Errorf("expected no stdout output (no contact sheet), got %q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(runDir, "contact-sheet.png")); err == nil {
|
|
t.Errorf("contact-sheet.png should not exist with --no-contact-sheet")
|
|
}
|
|
}
|
|
|
|
func TestCompareRecordsBackendErrors(t *testing.T) {
|
|
// One real (mock) + one unknown. Unknown should fail but not abort the
|
|
// run — sidecar records both, contact sheet built from successes only.
|
|
stderr, _, runDir, err := runCompareWithEnv(t, []string{
|
|
"y",
|
|
"--models", "mock,this-instance-does-not-exist",
|
|
"--size", "32x32",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("runCompare: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
sidecar := filepath.Join(runDir, "compare.json")
|
|
data, _ := os.ReadFile(sidecar)
|
|
var body struct {
|
|
Successful int `json:"successful"`
|
|
Total int `json:"total"`
|
|
Results []struct {
|
|
Backend string `json:"backend"`
|
|
Error string `json:"error"`
|
|
} `json:"results"`
|
|
}
|
|
if err := json.Unmarshal(data, &body); err != nil {
|
|
t.Fatalf("parse sidecar: %v", err)
|
|
}
|
|
if body.Total != 2 {
|
|
t.Errorf("expected 2 results, got %d", body.Total)
|
|
}
|
|
if body.Successful != 1 {
|
|
t.Errorf("expected 1 success, got %d", body.Successful)
|
|
}
|
|
var sawError bool
|
|
for _, r := range body.Results {
|
|
if r.Backend == "this-instance-does-not-exist" && r.Error != "" {
|
|
sawError = true
|
|
}
|
|
}
|
|
if !sawError {
|
|
t.Errorf("expected an error recorded for the unknown backend")
|
|
}
|
|
}
|
|
|
|
func TestCompareNoModelsFails(t *testing.T) {
|
|
_, _, _, err := runCompareWithEnv(t, []string{"x"})
|
|
if err == nil {
|
|
t.Fatal("expected error when --models is empty")
|
|
}
|
|
if !strings.Contains(err.Error(), "--models") {
|
|
t.Errorf("error should mention --models, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCompareNoPromptFails(t *testing.T) {
|
|
_, _, _, err := runCompareWithEnv(t, []string{"--models", "mock"})
|
|
if err == nil {
|
|
t.Fatal("expected error when prompt is missing")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing prompt") {
|
|
t.Errorf("error should mention missing prompt, got %v", err)
|
|
}
|
|
}
|