mAi: #211 - bootstrap ImaGen framework skeleton
First step of the model-agnostic image-generation framework. Lands the
plumbing other components (skill, ComfyUI/Replicate adapters, agents)
will plug into:
- internal/backend: Backend interface (Request/Result), thread-safe
Registry with init-time Register, plus a Mock reference adapter that
emits a deterministic gradient PNG for smoke tests.
- internal/config: YAML loader for ~/.config/imagen.yaml. Framework owns
default_backend + output settings + a per-backend block; each adapter
owns the schema below its own block via BackendSpec.Raw.
- internal/output: filename templating ({date}/{time}/{slug}/{seed}/
{backend}/{ext}), JSON metadata sidecar, --output override path.
- internal/prompt: embedded styles.yaml, style-preset suffix application.
- internal/server: 501 stub — HTTP surface lands in a follow-up issue.
- cmd/imagen: generate / backends / config (init|validate|path) / serve
/ version subcommands. Stdlib-only flag parsing with a small helper to
honour positional prompt args ahead of flags (matches the issue spec).
- Tests for output (slug, naming template, sidecar), backend (mock PNG
validity + determinism, registry build + duplicate panic), config
(round-trip + validation), prompt (style apply + unknown-style error).
- CLAUDE.md, README.md, docs/architecture.md, docs/usage.md, Makefile.
Acceptance criteria from #211:
1. go build ./... — clean
2. imagen backends — lists registered backends, exits 0
3. imagen generate "test prompt" --backend mock --output /tmp/x.png —
writes a 1024x1024 PNG plus an x.png.json sidecar
4. imagen config init | imagen config validate — round-trips cleanly
5. CLAUDE.md "Adding a new adapter" — six-step recipe
This commit is contained in:
51
cmd/imagen/backends.go
Normal file
51
cmd/imagen/backends.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/backend"
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
)
|
||||
|
||||
func runBackends(args []string) error {
|
||||
fs := flag.NewFlagSet("backends", flag.ContinueOnError)
|
||||
var configPath string
|
||||
fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, cfgErr := config.Load(configPath)
|
||||
if cfgErr != nil && !os.IsNotExist(cfgErr) {
|
||||
return cfgErr
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "INSTANCE\tTYPE\tSTATUS")
|
||||
if cfg != nil {
|
||||
for name, spec := range cfg.Backends {
|
||||
status := "registered"
|
||||
if !backend.Default.Has(spec.Type) {
|
||||
status = fmt.Sprintf("type %q not compiled in", spec.Type)
|
||||
}
|
||||
marker := ""
|
||||
if name == cfg.DefaultBackend {
|
||||
marker = " (default)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s%s\t%s\t%s\n", name, marker, spec.Type, status)
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
for _, t := range backend.Default.Types() {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", t, t, "no config — type registered, no instance defined")
|
||||
}
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "registered types:", backend.Default.Types())
|
||||
return nil
|
||||
}
|
||||
57
cmd/imagen/config.go
Normal file
57
cmd/imagen/config.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
)
|
||||
|
||||
func runConfig(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return userErr("usage: imagen config <init|validate|path>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "init":
|
||||
fmt.Print(config.Sample)
|
||||
return nil
|
||||
case "path":
|
||||
p, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(p)
|
||||
return nil
|
||||
case "validate":
|
||||
fs := flag.NewFlagSet("config validate", flag.ContinueOnError)
|
||||
var path string
|
||||
fs.StringVar(&path, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return userErr("no config file at %s — run `imagen config init > <path>` first", configPathOrDefault(path))
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "OK — %d backend(s) defined, default=%q\n",
|
||||
len(cfg.Backends), cfg.DefaultBackend)
|
||||
return nil
|
||||
default:
|
||||
return userErr("unknown config subcommand %q (init|validate|path)", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func configPathOrDefault(p string) string {
|
||||
if p != "" {
|
||||
return p
|
||||
}
|
||||
d, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return "~/.config/imagen.yaml"
|
||||
}
|
||||
return d
|
||||
}
|
||||
210
cmd/imagen/generate.go
Normal file
210
cmd/imagen/generate.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/backend"
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
"mgit.msbls.de/m/ImaGen/internal/output"
|
||||
"mgit.msbls.de/m/ImaGen/internal/prompt"
|
||||
)
|
||||
|
||||
func runGenerate(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
var (
|
||||
backendName string
|
||||
size string
|
||||
outPath string
|
||||
seed int64
|
||||
steps int
|
||||
style string
|
||||
negative string
|
||||
configPath string
|
||||
noSidecar bool
|
||||
)
|
||||
fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)")
|
||||
fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024")
|
||||
fs.StringVar(&outPath, "output", "", "explicit output path (overrides config naming template)")
|
||||
fs.Int64Var(&seed, "seed", 0, "deterministic seed (0 = backend default)")
|
||||
fs.IntVar(&steps, "steps", 0, "diffusion steps (0 = backend default)")
|
||||
fs.StringVar(&style, "style", "", "style preset name (see imagen config init for the list)")
|
||||
fs.StringVar(&negative, "negative", "", "negative prompt (ignored by backends that don't support it)")
|
||||
fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
||||
fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(fs.Output(), `Usage: imagen generate "<prompt>" [flags]`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
// stdlib flag stops parsing at the first non-flag arg, so split the
|
||||
// prompt (leading positional args) from the flags ourselves before parsing.
|
||||
leadingPositional, flagArgs := splitLeadingPositional(args)
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
positional := append(leadingPositional, fs.Args()...)
|
||||
if len(positional) == 0 {
|
||||
fs.Usage()
|
||||
return userErr("missing prompt")
|
||||
}
|
||||
rawPrompt := strings.Join(positional, " ")
|
||||
|
||||
w, h, err := parseSize(size)
|
||||
if err != nil {
|
||||
return userErr("bad --size: %v", err)
|
||||
}
|
||||
|
||||
cfg, cfgErr := config.Load(configPath)
|
||||
if cfgErr != nil && !os.IsNotExist(cfgErr) {
|
||||
return cfgErr
|
||||
}
|
||||
|
||||
if backendName == "" {
|
||||
if cfg != nil {
|
||||
backendName = cfg.DefaultBackend
|
||||
}
|
||||
}
|
||||
if backendName == "" {
|
||||
return userErr("no --backend given and no default_backend in config")
|
||||
}
|
||||
|
||||
be, err := buildBackend(cfg, backendName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
finalPrompt, err := prompt.Apply(rawPrompt, style)
|
||||
if err != nil {
|
||||
return userErr("%v", err)
|
||||
}
|
||||
|
||||
req := backend.Request{
|
||||
Prompt: finalPrompt,
|
||||
NegativePrompt: negative,
|
||||
Width: w,
|
||||
Height: h,
|
||||
Steps: steps,
|
||||
Seed: seed,
|
||||
Style: style,
|
||||
}
|
||||
res, err := be.Generate(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backend %q: %w", backendName, err)
|
||||
}
|
||||
defer res.ImageReader.Close()
|
||||
|
||||
writer := buildWriter(cfg, noSidecar)
|
||||
in := output.Inputs{
|
||||
Prompt: rawPrompt,
|
||||
Backend: be.Name(),
|
||||
Seed: seedFromMetadata(res.Metadata, seed),
|
||||
Ext: extFromMime(res.MimeType),
|
||||
Metadata: res.Metadata,
|
||||
}
|
||||
var paths *output.Outputs
|
||||
if outPath != "" {
|
||||
paths, err = writer.WriteToPath(res.ImageReader, outPath, in)
|
||||
} else {
|
||||
paths, err = writer.Write(res.ImageReader, in)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(paths.ImagePath)
|
||||
if paths.SidecarPath != "" {
|
||||
fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitLeadingPositional separates the positional args at the start of args
|
||||
// from the rest (which begins with the first flag). A literal "--" terminator
|
||||
// pushes everything after it into the positional list and out of flag parsing.
|
||||
func splitLeadingPositional(args []string) (positional, flags []string) {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
return append(positional, args[i+1:]...), flags
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
return positional, args[i:]
|
||||
}
|
||||
positional = append(positional, a)
|
||||
}
|
||||
return positional, flags
|
||||
}
|
||||
|
||||
func parseSize(s string) (int, int, error) {
|
||||
parts := strings.SplitN(s, "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("expected WxH, got %q", s)
|
||||
}
|
||||
w, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
h, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func buildBackend(cfg *config.Config, name string) (backend.Backend, error) {
|
||||
if cfg != nil {
|
||||
spec, ok := cfg.Backends[name]
|
||||
if ok {
|
||||
return backend.Default.Build(spec.Type, name, spec.Raw)
|
||||
}
|
||||
}
|
||||
if backend.Default.Has(name) {
|
||||
return backend.Default.Build(name, name, nil)
|
||||
}
|
||||
return nil, userErr("backend %q not found in config and not a registered type (registered types: %v)",
|
||||
name, backend.Default.Types())
|
||||
}
|
||||
|
||||
func buildWriter(cfg *config.Config, noSidecar bool) *output.Writer {
|
||||
w := &output.Writer{}
|
||||
if cfg != nil {
|
||||
w.Directory = config.ExpandPath(cfg.Output.Directory)
|
||||
w.NameTemplate = cfg.Output.Naming
|
||||
w.WriteSidecar = cfg.Output.WriteMetadataJSON
|
||||
}
|
||||
if w.Directory == "" {
|
||||
w.Directory = "."
|
||||
}
|
||||
if noSidecar {
|
||||
w.WriteSidecar = false
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func seedFromMetadata(meta map[string]any, fallback int64) int64 {
|
||||
if v, ok := meta["seed"]; ok {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func extFromMime(mime string) string {
|
||||
switch mime {
|
||||
case "image/png", "":
|
||||
return "png"
|
||||
case "image/jpeg":
|
||||
return "jpg"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
}
|
||||
return "bin"
|
||||
}
|
||||
77
cmd/imagen/main.go
Normal file
77
cmd/imagen/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Command imagen is the model-agnostic image-generation CLI. It dispatches
|
||||
// `generate`, `backends`, and `config` subcommands against backends that
|
||||
// register themselves at package init time.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "mgit.msbls.de/m/ImaGen/internal/backend"
|
||||
)
|
||||
|
||||
const usage = `imagen — model-agnostic image generation
|
||||
|
||||
Usage:
|
||||
imagen generate <prompt> [flags] generate one image
|
||||
imagen backends list registered backend types
|
||||
imagen config init print a sample imagen.yaml on stdout
|
||||
imagen config validate validate the active config
|
||||
imagen serve [--addr :8080] (stub) start the HTTP server
|
||||
imagen version print version
|
||||
imagen help show this help
|
||||
|
||||
Run "imagen <subcommand> --help" for subcommand-specific flags.
|
||||
`
|
||||
|
||||
// Version is overridable at link time via -ldflags '-X main.Version=...'.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
args := os.Args[2:]
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "generate":
|
||||
err = runGenerate(ctx, args)
|
||||
case "backends":
|
||||
err = runBackends(args)
|
||||
case "config":
|
||||
err = runConfig(args)
|
||||
case "serve":
|
||||
err = runServe(args)
|
||||
case "version", "-v", "--version":
|
||||
fmt.Println(Version)
|
||||
case "help", "-h", "--help":
|
||||
fmt.Print(usage)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "imagen: unknown subcommand %q\n\n%s", os.Args[1], usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "imagen:", err)
|
||||
var u *userError
|
||||
if errors.As(err, &u) {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// userError signals "user did the wrong thing" so we exit 2 rather than 1.
|
||||
type userError struct{ msg string }
|
||||
|
||||
func (u *userError) Error() string { return u.msg }
|
||||
func userErr(format string, a ...any) error {
|
||||
return &userError{msg: fmt.Sprintf(format, a...)}
|
||||
}
|
||||
21
cmd/imagen/serve.go
Normal file
21
cmd/imagen/serve.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/server"
|
||||
)
|
||||
|
||||
func runServe(args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
var addr string
|
||||
fs.StringVar(&addr, "addr", ":8080", "listen address")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := server.NotImplemented{}.Handler()
|
||||
fmt.Fprintf(fs.Output(), "imagen serve: stub responding 501 on %s — see internal/server for status\n", addr)
|
||||
return http.ListenAndServe(addr, srv)
|
||||
}
|
||||
Reference in New Issue
Block a user