Merge mai/bohr/issue-211-bootstrap: framework skeleton (#211)

This commit is contained in:
mAi
2026-05-08 14:37:24 +02:00
25 changed files with 1796 additions and 1 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/bin/
/dist/
*.test
*.out
.DS_Store
.env
.env.local
/imagen
/coverage.txt

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# ImaGen — Project Instructions
ImaGen is a model-agnostic image-generation framework. It has a single
opinionated CLI (`imagen`) that dispatches to whichever backend the user
configured — local FLUX on mRock via ComfyUI today, Replicate or DALL-E
tomorrow, something else next year. The framework owns plumbing (config,
output, naming, sidecars, prompt enrichment); each adapter owns the schema
and lifecycle of its own block in `~/.config/imagen.yaml`.
## Architecture
```
cmd/imagen/ CLI shell — generate, backends, config, serve
internal/backend/ Backend interface + Registry + Mock reference impl
internal/prompt/ Style preset registry (embedded styles.yaml)
internal/output/ Filename templating, image writer, JSON sidecar
internal/config/ YAML loader, validation, sample generator
internal/server/ HTTP stub (not implemented yet — follow-up issue)
docs/ architecture.md, usage.md
```
Data flow for `imagen generate`:
1. Parse flags, load config (`internal/config`).
2. Resolve the requested **instance name** to a config block, then the block's
`type` to a registered constructor in `backend.Default`.
3. Apply style preset (`internal/prompt`) to the prompt.
4. Call `backend.Generate(ctx, Request)`. The adapter returns a `*Result`
with an image stream + metadata.
5. Stream to disk via `internal/output`. If `write_metadata_json` is on, a
sidecar `<image>.json` is written next to it.
## Backend contract
```go
type Backend interface {
Name() string
Generate(ctx context.Context, req Request) (*Result, error)
}
```
`Request` carries the cross-backend fields (prompt, negative, size, steps,
seed, style preset, free-form `BackendOpts`). `Result` returns the image
bytes via an `io.ReadCloser`, the MIME type, and a metadata map (model name,
seed actually used, latency, cost-estimate, …).
## Adding a new adapter
1. Create `internal/backend/<adapter>.go` (e.g. `comfyui.go`). Define a struct
that holds whatever the adapter needs (HTTP client, model id, token).
2. Add a constructor `func New<Adapter>(name string, cfg map[string]any) (Backend, error)`.
Read fields from `cfg` — that map is the adapter's own block from
`imagen.yaml` minus the `type:` key. Resolve secrets from env vars
(`api_token_env`, `api_key_env`) — never accept tokens inline.
3. Implement `Name()` (return the user-facing instance name) and
`Generate(ctx, Request)`.
4. In `init()` call `Register("<type-name>", New<Adapter>)`.
5. Anonymous-import the package from `cmd/imagen/main.go` if it lives in a
separate package, so the `init()` runs.
6. Add a smoke test under `internal/backend/<adapter>_test.go`. Network tests
should be guarded by `testing.Short()` or an env var.
## Config
`~/.config/imagen.yaml` (override with `--config`). Top-level keys:
- `default_backend` — instance name used when `--backend` is omitted.
- `output.directory` / `output.naming` / `output.write_metadata_json`.
- `backends:` — map of instance-name → `{type, …adapter-specific…}`.
The framework parses `type` and stuffs the rest into `BackendSpec.Raw`. The
adapter is free to define any schema it likes inside its block.
## Credentials
Never hardcode. Always reference env-var names from the config:
```yaml
flux-dev-replicate:
type: replicate
api_token_env: REPLICATE_API_TOKEN
```
The adapter then `os.Getenv("REPLICATE_API_TOKEN")` at construction and fails
fast if unset. Tokens never go through `imagen.yaml` in plaintext.
## How the `/imagine` skill calls into imagen
The skill (issue #4) wraps `imagen generate` and post-processes the path it
prints on stdout. Slash-command surface area:
```
/imagine "a cat in a fishbowl" --style blog-header --size 1024x1024
```
The skill resolves to `imagen generate "<prompt>" --backend <default> …` and
returns the image path so otto can attach it to a chat reply.
## References
- mAi project conventions: `~/.m/docs/msystem.md`
- Backend follow-ups: ImaGen issues #2 (ComfyUI on mRock), #3 (Replicate), #4 (skill)
- mRock GPU: NVIDIA RTX 4070 Ti SUPER, 16 GB VRAM, runs Ollama + F5-TTS
## House rules
- No technical debt. No TODOs in landed code. If something can't be done now,
open an issue.
- All user-facing strings: ASCII or proper Unicode (Umlaute), never `ae/oe/ue`.
- Tests live next to the package they cover (`*_test.go`). No `tests/` dir.
- `go build ./...` and `go test ./...` must be clean before any commit.
- Run `task build` (or `make build`) for the full build; both call into
`go build -o bin/imagen ./cmd/imagen`.

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.PHONY: build install test lint clean smoke
BIN := bin/imagen
PKG := mgit.msbls.de/m/ImaGen/cmd/imagen
build:
go build -o $(BIN) ./cmd/imagen
install:
go install ./cmd/imagen
test:
go test ./...
lint:
go vet ./...
smoke: build
./$(BIN) generate "smoke test" --backend mock --output /tmp/imagen-smoke.png --no-sidecar
@file /tmp/imagen-smoke.png
clean:
rm -rf bin/

View File

@@ -1,3 +1,50 @@
# ImaGen # ImaGen
Model-agnostic image-generation framework: pluggable backends (local FLUX on mRock, Replicate, DALL-E, …) behind a single CLI/API/skill. Model-agnostic image-generation framework: pluggable backends (local FLUX on
mRock, Replicate, DALL-E, …) behind a single CLI / skill / API.
```
imagen generate "a cat in a fishbowl" --backend flux-schnell-local --size 1024x1024
```
See [`CLAUDE.md`](./CLAUDE.md) for the design — backend contract, registry,
config layout, how to add a new adapter.
## Install
```sh
go install mgit.msbls.de/m/ImaGen/cmd/imagen@latest
```
Or from a checkout:
```sh
make build # writes ./bin/imagen
make install # installs into $GOBIN (defaults to ~/go/bin)
```
## First run
```sh
mkdir -p ~/.config
imagen config init > ~/.config/imagen.yaml
imagen config validate
imagen backends
imagen generate "test prompt" --backend mock --output /tmp/x.png
```
The mock backend ships in this repo and produces a deterministic gradient
PNG — useful for smoke-testing the pipeline without reaching any model.
## Status
| Component | Status |
| ----------------------- | ------------- |
| Backend interface | done (#1) |
| Mock backend | done (#1) |
| ComfyUI / FLUX on mRock | open (#2) |
| Replicate adapter | open (#3) |
| `/imagine` skill | open (#4) |
| HTTP server | stubbed (#1) |
Issues live at <https://mgit.msbls.de/m/ImaGen/issues>.

51
cmd/imagen/backends.go Normal file
View 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
View 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
View 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
View 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
View 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)
}

110
docs/architecture.md Normal file
View File

@@ -0,0 +1,110 @@
# ImaGen architecture
ImaGen is intentionally small. The framework owns plumbing; adapters own the
upstream API. Each adapter only ever sees its own slice of `imagen.yaml`.
## Layers
```
┌───────────────────────┐
│ cmd/imagen │ CLI dispatch
│ (or HTTP server) │
└──────────┬────────────┘
┌──────────▼────────────┐
│ internal/prompt │ style preset → prompt suffix
│ internal/output │ filename templating, sidecar
│ internal/config │ YAML loader, validation
└──────────┬────────────┘
┌──────────▼────────────┐
│ internal/backend │ Backend interface + Registry
└──────────┬────────────┘
┌──────────▼────────────┐
│ adapters │ ComfyUI · Replicate · OpenAI · …
│ (each one register- │ each registers a `type` name on
│ s in init()) │ `backend.Default` at init time.
└───────────────────────┘
```
## The Backend contract
```go
type Request struct {
Prompt string
NegativePrompt string
Width, Height int
Steps int
Seed int64
Style string
BackendOpts map[string]any
}
type Result struct {
ImageReader io.ReadCloser
MimeType string
Metadata map[string]any
}
type Backend interface {
Name() string
Generate(ctx context.Context, req Request) (*Result, error)
}
```
Adapters translate `Request` into whatever the upstream expects. Fields they
can't honour (e.g. `NegativePrompt` on DALL-E) are silently ignored.
## Registry
`backend.Default` holds the process-wide name → constructor map. Each adapter
calls `backend.Register("<type>", NewX)` from its `init()`. The CLI imports
`internal/backend` (which transitively triggers the mock's init) and any
extra adapter packages.
## Config flow
```
imagen.yaml
backends:
flux-schnell-local:
type: comfyui ──┐
base_url: http://mrock:8188 │ framework keeps `type`,
model: flux1-schnell.safetensors │ hands the rest to the
default_steps: 4 │ comfyui adapter as cfg map[string]any
──┘
```
The framework never inspects fields below `type`. That's the adapter's
contract with itself, expressed however the adapter wants (typed struct,
map lookups, JSON tags — its call).
## Output
```
output:
directory: ~/Pictures/imagen
naming: "{date}-{slug}-{seed}.png"
write_metadata_json: true
```
Placeholders: `{date}`, `{time}`, `{slug}` (lowercased prompt, alnum-only,
truncated to 40 chars), `{seed}`, `{backend}`, `{ext}`. The sidecar JSON
contains the prompt, backend instance name, seed, ISO timestamp, and the
`Result.Metadata` map verbatim.
## Where adapters fail fast
- Missing required field in their config block — return an error from the
constructor; the CLI surfaces it as `imagen: backend "X": <err>`.
- Unset env-var for credentials — same.
- Network errors during `Generate` — wrap and return; no retry policy yet
(decide per-adapter, or move to a shared retry helper if a pattern emerges).
## Out of scope (today)
- Image post-processing (cropping, watermarking).
- Cost-tracking (lands with the Replicate adapter, since only API backends bill).
- Multi-image `n>1` per request — backends that support it can expose it via
`BackendOpts`; the framework doesn't have a first-class field yet.

73
docs/usage.md Normal file
View File

@@ -0,0 +1,73 @@
# Using imagen
## Subcommands
```
imagen generate <prompt> [flags] generate one image
imagen backends list configured + registered backends
imagen config init print a sample imagen.yaml on stdout
imagen config validate parse + validate the active config
imagen config path print the resolved config path
imagen serve [--addr :8080] (stub) start the HTTP server
imagen version print version
```
## `generate` flags
| Flag | Default | Notes |
| -------------- | ---------------------------- | ----------------------------------------------------------- |
| `--backend` | `default_backend` from config | Instance name from `imagen.yaml` |
| `--size` | `1024x1024` | `WxH` |
| `--seed` | `0` (= backend default) | |
| `--steps` | `0` (= backend default) | |
| `--style` | empty | One of `imagen config init`'s style names |
| `--negative` | empty | Negative prompt (ignored by some adapters) |
| `--output` | empty (= use naming template) | Explicit path |
| `--no-sidecar` | `false` | Skip the JSON sidecar even if config enables it |
| `--config` | `~/.config/imagen.yaml` | Override config path |
## Examples
```sh
# Quick smoke test — mock backend ships in-tree
imagen generate "test" --backend mock --output /tmp/x.png
# Real generation, FLUX-schnell on mRock via ComfyUI
imagen generate "a wide editorial blog header about RAG systems" \
--backend flux-schnell-local \
--style blog-header \
--size 1536x768
# Explicit seed for reproducibility
imagen generate "a cat in a fishbowl" --backend mock --seed 42 --output /tmp/cat.png
```
## Config
A complete sample is in `imagen config init`. Adapters get only their own
sub-block — see [`../CLAUDE.md`](../CLAUDE.md) for the contract.
## Naming template
`output.naming` placeholders:
| Placeholder | Replaced with |
| ----------- | ---------------------------------------- |
| `{date}` | `2026-05-08` |
| `{time}` | `143015` (no separators) |
| `{slug}` | lowercased ASCII prompt, ≤ 40 chars |
| `{seed}` | seed actually used |
| `{backend}` | backend instance name |
| `{ext}` | file extension matching `Result.MimeType` |
Unknown placeholders are left literal.
## Credentials
API-backed adapters read tokens from env vars referenced by the config
(`api_token_env`, `api_key_env`). Never put a token in `imagen.yaml`.
```sh
export REPLICATE_API_TOKEN=...
imagen generate "a cat" --backend flux-dev-replicate
```

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module mgit.msbls.de/m/ImaGen
go 1.24
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,37 @@
// Package backend defines the model-agnostic contract every image-generation
// adapter must satisfy. The framework speaks only through Backend; concrete
// adapters (ComfyUI, Replicate, OpenAI, …) translate Request into whatever
// the upstream API expects and return a Result.
package backend
import (
"context"
"io"
)
// Request is the cross-backend request shape. Adapters translate it
// to whatever their target API expects. Zero values mean "use backend default"
// unless documented otherwise.
type Request struct {
Prompt string
NegativePrompt string
Width, Height int
Steps int
Seed int64
Style string
BackendOpts map[string]any
}
// Result is what the backend produces. The caller is responsible for closing
// ImageReader.
type Result struct {
ImageReader io.ReadCloser
MimeType string
Metadata map[string]any
}
// Backend is the interface every adapter satisfies.
type Backend interface {
Name() string
Generate(ctx context.Context, req Request) (*Result, error)
}

View File

@@ -0,0 +1,93 @@
package backend
import (
"bytes"
"context"
"image/png"
"io"
"testing"
)
func TestMockGeneratesValidPNG(t *testing.T) {
be, err := NewMock("mock", nil)
if err != nil {
t.Fatalf("NewMock: %v", err)
}
if be.Name() != "mock" {
t.Errorf("Name() = %q", be.Name())
}
res, err := be.Generate(context.Background(), Request{
Prompt: "test prompt",
Width: 64,
Height: 64,
Seed: 1234,
})
if err != nil {
t.Fatalf("Generate: %v", err)
}
defer res.ImageReader.Close()
if res.MimeType != "image/png" {
t.Errorf("mime = %q", res.MimeType)
}
body, err := io.ReadAll(res.ImageReader)
if err != nil {
t.Fatalf("read body: %v", err)
}
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
t.Fatalf("decode png: %v", err)
}
if img.Bounds().Dx() != 64 || img.Bounds().Dy() != 64 {
t.Errorf("dims = %v", img.Bounds())
}
if seed, ok := res.Metadata["seed"].(int64); !ok || seed != 1234 {
t.Errorf("metadata seed = %v", res.Metadata["seed"])
}
}
func TestMockDeterministicBySeed(t *testing.T) {
be, _ := NewMock("mock", nil)
gen := func() []byte {
res, err := be.Generate(context.Background(), Request{Prompt: "p", Width: 32, Height: 32, Seed: 99})
if err != nil {
t.Fatalf("Generate: %v", err)
}
defer res.ImageReader.Close()
b, _ := io.ReadAll(res.ImageReader)
return b
}
a := gen()
b := gen()
if !bytes.Equal(a, b) {
t.Errorf("same seed produced different images: %d vs %d bytes", len(a), len(b))
}
}
func TestRegistryBuildAndUnknown(t *testing.T) {
r := NewRegistry()
r.Register("mock", NewMock)
if !r.Has("mock") {
t.Errorf("Has(mock) = false")
}
be, err := r.Build("mock", "instance-1", nil)
if err != nil {
t.Fatalf("Build: %v", err)
}
if be.Name() != "instance-1" {
t.Errorf("Name() = %q", be.Name())
}
if _, err := r.Build("nope", "x", nil); err == nil {
t.Errorf("expected error for unknown type")
}
}
func TestRegistryDuplicatePanic(t *testing.T) {
r := NewRegistry()
r.Register("dup", NewMock)
defer func() {
if recover() == nil {
t.Errorf("expected panic on duplicate Register")
}
}()
r.Register("dup", NewMock)
}

116
internal/backend/mock.go Normal file
View File

@@ -0,0 +1,116 @@
package backend
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math/rand"
"time"
)
// Mock is a deterministic image generator used for tests, smoke checks and as
// the reference implementation of the Backend contract. Same seed + same prompt
// always yields the same PNG. It does not call the network.
type Mock struct {
instance string
}
// NewMock builds a Mock backend. cfg is accepted for symmetry with real
// adapters but is ignored.
func NewMock(name string, _ map[string]any) (Backend, error) {
if name == "" {
name = "mock"
}
return &Mock{instance: name}, nil
}
// Name returns the user-facing instance name.
func (m *Mock) Name() string { return m.instance }
// Generate paints a deterministic gradient sized to req.Width×req.Height and
// returns it as a PNG. Width/Height default to 256 when zero.
func (m *Mock) Generate(ctx context.Context, req Request) (*Result, error) {
w, h := req.Width, req.Height
if w == 0 {
w = 256
}
if h == 0 {
h = 256
}
if w < 1 || h < 1 || w > 8192 || h > 8192 {
return nil, fmt.Errorf("mock: invalid size %dx%d", w, h)
}
seed := req.Seed
if seed == 0 {
seed = derivedSeed(req.Prompt)
}
rng := rand.New(rand.NewSource(seed))
baseR := uint8(rng.Intn(256))
baseG := uint8(rng.Intn(256))
baseB := uint8(rng.Intn(256))
start := time.Now()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
for x := 0; x < w; x++ {
fx := float64(x) / float64(w)
fy := float64(y) / float64(h)
img.Set(x, y, color.RGBA{
R: blend(baseR, fx),
G: blend(baseG, fy),
B: blend(baseB, (fx+fy)/2),
A: 255,
})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("mock: encode png: %w", err)
}
return &Result{
ImageReader: io.NopCloser(&buf),
MimeType: "image/png",
Metadata: map[string]any{
"backend": m.instance,
"backend_type": "mock",
"seed": seed,
"width": w,
"height": h,
"latency_ms": time.Since(start).Milliseconds(),
},
}, nil
}
func blend(base uint8, f float64) uint8 {
v := float64(base) + (255-float64(base))*f
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
return uint8(v)
}
// derivedSeed produces a stable int64 seed from a prompt so tests are
// reproducible without forcing the caller to pick one.
func derivedSeed(prompt string) int64 {
sum := sha256.Sum256([]byte(prompt))
return int64(binary.BigEndian.Uint64(sum[:8]) >> 1)
}
func init() {
Register("mock", NewMock)
}

View File

@@ -0,0 +1,75 @@
package backend
import (
"fmt"
"sort"
"sync"
)
// Constructor builds a Backend from a name and its sub-block of raw config.
// The framework hands the adapter only its own slice of imagen.yaml, so the
// adapter owns its schema completely.
type Constructor func(name string, cfg map[string]any) (Backend, error)
// Registry holds the name → Constructor table. Adapters call Register from
// their package init() to make themselves available to the CLI.
type Registry struct {
mu sync.RWMutex
ctors map[string]Constructor
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{ctors: make(map[string]Constructor)}
}
// Register adds a constructor under typeName (e.g. "comfyui", "mock").
// Re-registering an existing type panics — names are global per binary.
func (r *Registry) Register(typeName string, ctor Constructor) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.ctors[typeName]; exists {
panic(fmt.Sprintf("backend type %q already registered", typeName))
}
r.ctors[typeName] = ctor
}
// Build instantiates a backend of typeName using cfg. instanceName is the
// user-facing name from imagen.yaml (e.g. "flux-schnell-local").
func (r *Registry) Build(typeName, instanceName string, cfg map[string]any) (Backend, error) {
r.mu.RLock()
ctor, ok := r.ctors[typeName]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("backend type %q not registered, available: %v", typeName, r.Types())
}
return ctor(instanceName, cfg)
}
// Types returns the registered backend type names, sorted.
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.ctors))
for k := range r.ctors {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Has reports whether typeName is registered.
func (r *Registry) Has(typeName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.ctors[typeName]
return ok
}
// Default is the process-wide registry adapters register against.
var Default = NewRegistry()
// Register is shorthand for Default.Register.
func Register(typeName string, ctor Constructor) {
Default.Register(typeName, ctor)
}

143
internal/config/config.go Normal file
View File

@@ -0,0 +1,143 @@
// Package config loads ~/.config/imagen.yaml. The framework knows the global
// shape (default backend + output settings + a per-backend block); each
// adapter owns the schema of its own block.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config is the top-level shape of imagen.yaml.
type Config struct {
DefaultBackend string `yaml:"default_backend"`
Output OutputConfig `yaml:"output"`
Backends map[string]BackendSpec `yaml:"backends"`
}
// OutputConfig controls where generated images and metadata sidecars land.
type OutputConfig struct {
Directory string `yaml:"directory"`
Naming string `yaml:"naming"`
WriteMetadataJSON bool `yaml:"write_metadata_json"`
}
// BackendSpec is one entry under `backends:`. Type identifies the adapter;
// the rest is opaque to the framework and handed to the adapter as-is.
type BackendSpec struct {
Type string `yaml:"type"`
Raw map[string]any `yaml:",inline"`
}
// DefaultPath returns ~/.config/imagen.yaml, honouring XDG_CONFIG_HOME.
func DefaultPath() (string, error) {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "imagen.yaml"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "imagen.yaml"), nil
}
// Load reads and validates the config at path. If path is empty the default
// path is used. A missing file returns os.ErrNotExist so callers can decide
// whether to fall back to defaults.
func Load(path string) (*Config, error) {
if path == "" {
p, err := DefaultPath()
if err != nil {
return nil, err
}
path = p
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate %s: %w", path, err)
}
return cfg, nil
}
// Validate enforces the framework-level invariants. Adapter-level validation
// happens when the adapter constructor runs.
func (c *Config) Validate() error {
if c.DefaultBackend != "" {
if _, ok := c.Backends[c.DefaultBackend]; !ok {
return fmt.Errorf("default_backend %q is not defined under backends:", c.DefaultBackend)
}
}
for name, spec := range c.Backends {
if name == "" {
return errors.New("empty backend name")
}
if spec.Type == "" {
return fmt.Errorf("backend %q is missing a type:", name)
}
}
return nil
}
// Sample is the canonical example written by `imagen config init`.
const Sample = `# imagen.yaml — config for the imagen CLI.
# Adapters get only their own sub-block at construction. Add a new backend by
# implementing the Backend interface, registering its type name, and listing
# an instance here.
default_backend: mock
output:
directory: ~/Pictures/imagen
naming: "{date}-{slug}-{seed}.png"
write_metadata_json: true
backends:
mock:
type: mock
flux-schnell-local:
type: comfyui
base_url: http://mrock:8188
model: flux1-schnell.safetensors
default_steps: 4
flux-dev-replicate:
type: replicate
api_token_env: REPLICATE_API_TOKEN
model: black-forest-labs/flux-dev
default_steps: 28
dalle3:
type: openai
api_key_env: OPENAI_API_KEY
model: dall-e-3
`
// ExpandPath resolves leading ~ to the user's home directory.
func ExpandPath(p string) string {
if p == "" || p[0] != '~' {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return p
}
if len(p) == 1 {
return home
}
if p[1] == '/' {
return filepath.Join(home, p[2:])
}
return p
}

View File

@@ -0,0 +1,70 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadAndValidate(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "imagen.yaml")
if err := os.WriteFile(path, []byte(Sample), 0o644); err != nil {
t.Fatalf("write sample: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DefaultBackend != "mock" {
t.Errorf("default = %q", cfg.DefaultBackend)
}
mock, ok := cfg.Backends["mock"]
if !ok {
t.Fatalf("mock backend missing")
}
if mock.Type != "mock" {
t.Errorf("mock type = %q", mock.Type)
}
flux, ok := cfg.Backends["flux-schnell-local"]
if !ok {
t.Fatalf("flux backend missing")
}
if flux.Raw["base_url"] != "http://mrock:8188" {
t.Errorf("flux base_url = %v", flux.Raw["base_url"])
}
}
func TestValidateRejectsUnknownDefault(t *testing.T) {
c := &Config{
DefaultBackend: "ghost",
Backends: map[string]BackendSpec{"real": {Type: "mock"}},
}
if err := c.Validate(); err == nil {
t.Errorf("expected error for unknown default_backend")
}
}
func TestValidateRejectsMissingType(t *testing.T) {
c := &Config{
Backends: map[string]BackendSpec{"x": {}},
}
if err := c.Validate(); err == nil {
t.Errorf("expected error for missing type")
}
}
func TestExpandPath(t *testing.T) {
home, _ := os.UserHomeDir()
cases := map[string]string{
"": "",
"/abs/path": "/abs/path",
"~": home,
"~/foo/bar": filepath.Join(home, "foo/bar"),
}
for in, want := range cases {
if got := ExpandPath(in); got != want {
t.Errorf("ExpandPath(%q) = %q, want %q", in, got, want)
}
}
}

184
internal/output/output.go Normal file
View File

@@ -0,0 +1,184 @@
// Package output writes generated images to disk and (optionally) a JSON
// metadata sidecar. Filenames are resolved through a small template language
// with placeholders {date}, {time}, {slug}, {seed}, {backend}, {ext}.
package output
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// Writer renders one generation to disk under Directory.
type Writer struct {
Directory string
NameTemplate string
WriteSidecar bool
Now func() time.Time
}
// Inputs are the ingredients needed to compute a filename and write a sidecar.
type Inputs struct {
Prompt string
Backend string
Seed int64
Ext string
Metadata map[string]any
}
// Outputs lists the artefacts the writer produced.
type Outputs struct {
ImagePath string
SidecarPath string
}
// Write streams img to disk and, if enabled, writes a sidecar. The image
// stream is consumed even on error so callers don't leak goroutines from
// piped readers.
func (w *Writer) Write(img io.Reader, in Inputs) (*Outputs, error) {
now := w.now()
ext := in.Ext
if ext == "" {
ext = "png"
}
tmpl := w.NameTemplate
if tmpl == "" {
tmpl = "{date}-{slug}-{seed}.{ext}"
}
name := renderTemplate(tmpl, map[string]string{
"date": now.Format("2006-01-02"),
"time": now.Format("150405"),
"slug": Slug(in.Prompt),
"seed": fmt.Sprintf("%d", in.Seed),
"backend": in.Backend,
"ext": strings.TrimPrefix(ext, "."),
})
dir := w.Directory
if dir == "" {
dir = "."
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
}
imagePath := filepath.Join(dir, name)
f, err := os.Create(imagePath)
if err != nil {
return nil, fmt.Errorf("create %s: %w", imagePath, err)
}
if _, err := io.Copy(f, img); err != nil {
f.Close()
return nil, fmt.Errorf("write %s: %w", imagePath, err)
}
if err := f.Close(); err != nil {
return nil, fmt.Errorf("close %s: %w", imagePath, err)
}
out := &Outputs{ImagePath: imagePath}
if w.WriteSidecar {
sidecar := imagePath + ".json"
body := map[string]any{
"timestamp": now.UTC().Format(time.RFC3339),
"prompt": in.Prompt,
"backend": in.Backend,
"seed": in.Seed,
"image": filepath.Base(imagePath),
"metadata": in.Metadata,
}
data, err := json.MarshalIndent(body, "", " ")
if err != nil {
return out, fmt.Errorf("marshal sidecar: %w", err)
}
if err := os.WriteFile(sidecar, append(data, '\n'), 0o644); err != nil {
return out, fmt.Errorf("write sidecar %s: %w", sidecar, err)
}
out.SidecarPath = sidecar
}
return out, nil
}
// WriteToPath bypasses templating and writes img to an explicit path. This is
// the path the CLI's --output flag uses.
func (w *Writer) WriteToPath(img io.Reader, path string, in Inputs) (*Outputs, error) {
now := w.now()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("create %s: %w", path, err)
}
if _, err := io.Copy(f, img); err != nil {
f.Close()
return nil, fmt.Errorf("write %s: %w", path, err)
}
if err := f.Close(); err != nil {
return nil, fmt.Errorf("close %s: %w", path, err)
}
out := &Outputs{ImagePath: path}
if w.WriteSidecar {
sidecar := path + ".json"
body := map[string]any{
"timestamp": now.UTC().Format(time.RFC3339),
"prompt": in.Prompt,
"backend": in.Backend,
"seed": in.Seed,
"image": filepath.Base(path),
"metadata": in.Metadata,
}
data, err := json.MarshalIndent(body, "", " ")
if err != nil {
return out, fmt.Errorf("marshal sidecar: %w", err)
}
if err := os.WriteFile(sidecar, append(data, '\n'), 0o644); err != nil {
return out, fmt.Errorf("write sidecar %s: %w", sidecar, err)
}
out.SidecarPath = sidecar
}
return out, nil
}
func (w *Writer) now() time.Time {
if w.Now != nil {
return w.Now()
}
return time.Now()
}
var (
tmplPlaceholder = regexp.MustCompile(`\{([a-z]+)\}`)
slugAllowed = regexp.MustCompile(`[^a-z0-9]+`)
)
func renderTemplate(t string, vars map[string]string) string {
return tmplPlaceholder.ReplaceAllStringFunc(t, func(match string) string {
key := match[1 : len(match)-1]
if v, ok := vars[key]; ok {
return v
}
return match
})
}
// Slug normalises a prompt fragment into a filesystem-safe token.
func Slug(s string) string {
s = strings.ToLower(s)
s = slugAllowed.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "image"
}
const max = 40
if len(s) > max {
s = s[:max]
s = strings.TrimRight(s, "-")
}
return s
}

View File

@@ -0,0 +1,127 @@
package output
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSlug(t *testing.T) {
cases := map[string]string{
"": "image",
" ": "image",
"A Cat in a Fishbowl": "a-cat-in-a-fishbowl",
"!!! weird---input": "weird-input",
"über-cool prompt": "ber-cool-prompt", // ASCII-only by design
strings.Repeat("a", 80): strings.Repeat("a", 40),
}
for in, want := range cases {
if got := Slug(in); got != want {
t.Errorf("Slug(%q) = %q, want %q", in, got, want)
}
}
}
func TestRenderTemplateAndWrite(t *testing.T) {
dir := t.TempDir()
w := &Writer{
Directory: dir,
NameTemplate: "{date}-{slug}-{seed}.{ext}",
WriteSidecar: true,
Now: func() time.Time {
return time.Date(2026, 5, 8, 14, 30, 15, 0, time.UTC)
},
}
body := []byte("PNGbytes")
out, err := w.Write(bytes.NewReader(body), Inputs{
Prompt: "A cat in a fishbowl",
Backend: "mock",
Seed: 42,
Ext: "png",
Metadata: map[string]any{"foo": "bar"},
})
if err != nil {
t.Fatalf("Write: %v", err)
}
want := filepath.Join(dir, "2026-05-08-a-cat-in-a-fishbowl-42.png")
if out.ImagePath != want {
t.Errorf("image path = %q, want %q", out.ImagePath, want)
}
gotBody, err := os.ReadFile(out.ImagePath)
if err != nil {
t.Fatalf("read image: %v", err)
}
if !bytes.Equal(gotBody, body) {
t.Errorf("image body mismatch")
}
if out.SidecarPath == "" {
t.Fatal("sidecar path empty")
}
sc, err := os.ReadFile(out.SidecarPath)
if err != nil {
t.Fatalf("read sidecar: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(sc, &parsed); err != nil {
t.Fatalf("sidecar json: %v\n%s", err, sc)
}
if parsed["prompt"] != "A cat in a fishbowl" {
t.Errorf("sidecar prompt = %v", parsed["prompt"])
}
if parsed["backend"] != "mock" {
t.Errorf("sidecar backend = %v", parsed["backend"])
}
if parsed["timestamp"] != "2026-05-08T14:30:15Z" {
t.Errorf("sidecar timestamp = %v", parsed["timestamp"])
}
meta, ok := parsed["metadata"].(map[string]any)
if !ok || meta["foo"] != "bar" {
t.Errorf("sidecar metadata = %v", parsed["metadata"])
}
}
func TestWriteSkipSidecarWhenDisabled(t *testing.T) {
dir := t.TempDir()
w := &Writer{Directory: dir, WriteSidecar: false}
out, err := w.Write(bytes.NewReader([]byte("x")), Inputs{Prompt: "p", Backend: "b", Seed: 1, Ext: "png"})
if err != nil {
t.Fatalf("Write: %v", err)
}
if out.SidecarPath != "" {
t.Errorf("sidecar path = %q, want empty", out.SidecarPath)
}
}
func TestUnknownPlaceholderPassesThrough(t *testing.T) {
got := renderTemplate("{date}-{nonsense}-{seed}", map[string]string{
"date": "2026-05-08", "seed": "1",
})
if got != "2026-05-08-{nonsense}-1" {
t.Errorf("got %q", got)
}
}
func TestWriteToPath(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "explicit.png")
w := &Writer{WriteSidecar: true}
out, err := w.WriteToPath(bytes.NewReader([]byte("z")), target, Inputs{
Prompt: "p", Backend: "b", Seed: 7, Ext: "png",
})
if err != nil {
t.Fatalf("WriteToPath: %v", err)
}
if out.ImagePath != target {
t.Errorf("image path = %q, want %q", out.ImagePath, target)
}
if _, err := os.Stat(target); err != nil {
t.Errorf("expected %s to exist: %v", target, err)
}
if _, err := os.Stat(target + ".json"); err != nil {
t.Errorf("expected sidecar to exist: %v", err)
}
}

63
internal/prompt/prompt.go Normal file
View File

@@ -0,0 +1,63 @@
// Package prompt enriches a raw user prompt before it reaches a backend. The
// only enrichment today is style-preset suffixes; future passes can add
// negative-prompt resolution, safety filters, etc.
package prompt
import (
_ "embed"
"fmt"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
//go:embed styles.yaml
var stylesYAML []byte
type stylesFile struct {
Styles map[string]string `yaml:"styles"`
}
var defaultStyles map[string]string
func init() {
var f stylesFile
if err := yaml.Unmarshal(stylesYAML, &f); err != nil {
panic(fmt.Sprintf("prompt: parse embedded styles.yaml: %v", err))
}
defaultStyles = f.Styles
}
// Styles returns the names of registered style presets, sorted.
func Styles() []string {
out := make([]string, 0, len(defaultStyles))
for k := range defaultStyles {
out = append(out, k)
}
sort.Strings(out)
return out
}
// HasStyle reports whether name is a known preset.
func HasStyle(name string) bool {
_, ok := defaultStyles[name]
return ok
}
// Apply returns the prompt with the named style preset appended. Unknown
// styles return an error rather than silently passing the prompt through —
// surprising the caller with missing style is worse than a hard error.
func Apply(prompt, style string) (string, error) {
if style == "" {
return prompt, nil
}
suffix, ok := defaultStyles[style]
if !ok {
return "", fmt.Errorf("unknown style %q, available: %v", style, Styles())
}
if strings.TrimSpace(prompt) == "" {
return suffix, nil
}
return prompt + ", " + suffix, nil
}

View File

@@ -0,0 +1,50 @@
package prompt
import "testing"
func TestApplyKnownStyle(t *testing.T) {
got, err := Apply("a cat", "photo")
if err != nil {
t.Fatalf("Apply: %v", err)
}
want := "a cat, photorealistic, sharp focus, natural lighting"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestApplyEmptyStylePassThrough(t *testing.T) {
got, err := Apply("a cat", "")
if err != nil || got != "a cat" {
t.Errorf("got (%q,%v)", got, err)
}
}
func TestApplyUnknownStyleErrors(t *testing.T) {
if _, err := Apply("a cat", "nonsense"); err == nil {
t.Errorf("expected error for unknown style")
}
}
func TestApplyToEmptyPromptUsesPresetOnly(t *testing.T) {
got, err := Apply("", "photo")
if err != nil {
t.Fatalf("Apply: %v", err)
}
if got == "" || got[0] == ',' {
t.Errorf("unexpected output %q", got)
}
}
func TestStylesContainsAllExpected(t *testing.T) {
want := []string{"blog-header", "diagram", "illustration", "photo", "sketch"}
got := Styles()
if len(got) != len(want) {
t.Fatalf("Styles() = %v, want %v", got, want)
}
for i, w := range want {
if got[i] != w {
t.Errorf("Styles()[%d] = %q, want %q", i, got[i], w)
}
}
}

View File

@@ -0,0 +1,6 @@
styles:
photo: "photorealistic, sharp focus, natural lighting"
illustration: "digital illustration, clean lines, vibrant colors"
diagram: "minimal technical diagram, isometric, white background, line-art"
sketch: "rough pencil sketch, hand-drawn, monochrome"
blog-header: "wide aspect, conceptual, soft palette, editorial illustration"

31
internal/server/server.go Normal file
View File

@@ -0,0 +1,31 @@
// Package server is a placeholder for the HTTP surface that lets non-Go
// callers (skills, agents, otto) drive imagen without exec-ing the CLI.
//
// The CLI already covers the v0 use cases, so this package intentionally
// ships only an interface and a 501 stub. A follow-up issue (tracked after
// the ComfyUI + Replicate adapters land) will flesh it out.
package server
import (
"fmt"
"net/http"
)
// Server is the eventual HTTP shape. Concrete implementations can wrap a
// backend.Registry + config.Config and expose POST /v1/generate.
type Server interface {
Handler() http.Handler
}
// NotImplemented is the placeholder the CLI wires up if someone calls
// `imagen serve` before the real server lands.
type NotImplemented struct{}
// Handler returns an http.Handler that responds 501 to every request.
func (NotImplemented) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotImplemented)
fmt.Fprintln(w, "imagen HTTP server: not implemented yet — use the CLI for now")
})
}