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.
194 lines
5.0 KiB
Go
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
|
|
}
|