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
117 lines
2.6 KiB
Go
117 lines
2.6 KiB
Go
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)
|
||
}
|