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
185 lines
4.6 KiB
Go
185 lines
4.6 KiB
Go
// 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
|
|
}
|