Files
ImaGen/internal/output/output.go
mAi e22f286024 mAi: #7 - cloud-sync to Supabase Storage + imagen.images
Every successful imagen generate now (a) uploads the PNG to the private
imagen-generated bucket and (b) inserts a row into imagen.images, the
data plane the flexsiebels owner-mode viewer reads from.

Schema, RLS, indexes, bucket and PostgREST exposure landed via four
applied migrations on msupabase: imagen_schema_init,
imagen_schema_grants, imagen_storage_policies, imagen_pgrst_expose
(authenticator role-level ALTER + reload). Owner UUID for m:
ac6c9501-3757-4a6d-8b97-2cff4288382b — documented in the config sample.

Code: new internal/cloud/ package mirroring the internal/usage/ shape.
PostgREST POST against the imagen schema (Accept-Profile + Content-
Profile headers), Storage upload via PUT with x-upsert, retry on 5xx /
transport but not 4xx, owner_user_id required (the column is NOT NULL
and the read-side RLS policy needs it).

Wiring in cmd/imagen/generate.go: --no-cloud flag, output.cloud_sync
config knob (auto|on|off mirroring --preview), $IMAGEN_CLOUD_SYNC env
override. The hook reads the just-written PNG + sidecar from disk and
calls cloud.Sync; failures emit "imagen: cloud sync: <err>" to stderr
without changing exit code, so a Supabase blip never loses the artefact.
output.Outputs grew Date/Slug/Seed fields so storage_path mirrors the
local filename's prefix exactly (no UTC-vs-local drift).

Config: owner_user_id field added; sample comment points at the
auth.users lookup. imagen config validate warns on stderr when
cloud_sync is on/auto but owner_user_id is empty.

Tests: cloud_test.go covers happy path, retry-on-5xx, no-retry-on-4xx,
missing-owner-uuid, missing-date-or-slug, signed URL, and the partial-
success case where the upload landed but the DB insert failed.
generate_test.go covers the precedence chain for cloud-sync mode
resolution. Build + tests clean across the tree.

Real smoke against mRock: generation through flux-schnell-local writes
the local PNG + sidecar AND uploads to imagen-generated/2026-05-11/...
AND inserts into imagen.images. Signed URL round-trips the same bytes.
--no-cloud verified to skip both Storage and DB.
2026-05-11 01:51:09 +02:00

194 lines
5.0 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
// Date is the YYYY-MM-DD the writer used for the filename. Cloud sync
// reuses this so storage_path matches the local filename's date.
Date string
// Slug is the filename-safe prompt fragment the writer used.
Slug string
// Seed is the seed value baked into the filename.
Seed int64
}
// 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}"
}
date := now.Format("2006-01-02")
slug := Slug(in.Prompt)
name := renderTemplate(tmpl, map[string]string{
"date": date,
"time": now.Format("150405"),
"slug": slug,
"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, Date: date, Slug: slug, Seed: in.Seed}
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, Date: now.Format("2006-01-02"), Slug: Slug(in.Prompt), Seed: in.Seed}
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
}