Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cmd/imgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func main() {
defer st.Close()
hub := notify.NewWebSocketHub()
serveGen := serveGenerator(generator, cfg)
svc := &service.Service{Store: st, Generator: serveGen, PromptPrefix: cfg.Backend.Prompt.Prefix, PromptPrelude: cfg.Backend.Prompt.Prelude, DefaultJobConcurrency: cfg.Scheduler.DefaultJobConcurrency, MaxJobConcurrency: cfg.Scheduler.MaxJobConcurrency, MaxCountPerJob: cfg.Scheduler.MaxCountPerJob, MaxAttempts: cfg.Scheduler.MaxAttempts, Publisher: hub}
svc := &service.Service{Store: st, Generator: serveGen, PromptPrefix: cfg.Backend.Prompt.Prefix, PromptPrelude: cfg.Backend.Prompt.Prelude, DefaultJobConcurrency: cfg.Scheduler.DefaultJobConcurrency, MaxJobConcurrency: cfg.Scheduler.MaxJobConcurrency, MaxCountPerJob: cfg.Scheduler.MaxCountPerJob, ImageInputDir: imageInputDir(dataDir, cfg), MaxAttempts: cfg.Scheduler.MaxAttempts, Publisher: hub}
handler := api.NewServerWithOptions(svc, api.ServerOptions{
Notifier: hub,
Realtime: realtimeOptions(serveGen, cfg),
Expand All @@ -72,7 +72,8 @@ func main() {
maintenance := scheduler.Maintenance{Store: st, Mailer: notify.NewMailer(cfg.Email), LeaseTimeout: cfg.Scheduler.TaskLeaseTimeout}
app.ServerRunner = cli.HTTPServerRunner{Server: server, Maintenance: cli.MaintenanceAdapter{Maintenance: maintenance}, MaintenanceInterval: cfg.Scheduler.MaintenanceInterval}
case "submit", "status", "get", "list", "cancel":
app.Client = &cli.Client{BaseURL: "http://" + cfg.Server.Listen}
dataDir, _ := storagePaths(home, cfg)
app.Client = &cli.Client{BaseURL: "http://" + cfg.Server.Listen, ImageInputDir: imageInputDir(dataDir, cfg)}
case "doctor":
app.OpenClawDoctor = doctor.NewOpenClawChecker(home)
default:
Expand Down Expand Up @@ -112,6 +113,13 @@ func storagePaths(home string, cfg config.Config) (string, string) {
return dataDir, dbPath
}

func imageInputDir(dataDir string, cfg config.Config) string {
if cfg.Storage.ImageInputDir != "" {
return cfg.Storage.ImageInputDir
}
return filepath.Join(dataDir, "input_images")
}

func mustOpenStore(dbPath string) *store.Store {
_ = os.MkdirAll(filepath.Dir(dbPath), 0o755)
st, err := store.Open(dbPath)
Expand Down
24 changes: 24 additions & 0 deletions cmd/imgen/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"path/filepath"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -63,6 +64,29 @@ func TestRealtimeOptionsUsesConfig(t *testing.T) {
}
}

func TestImageInputDirDefaultsUnderDataDir(t *testing.T) {
cfg := config.Default()
dataDir := filepath.Join(t.TempDir(), "data")

got := imageInputDir(dataDir, cfg)

want := filepath.Join(dataDir, "input_images")
if got != want {
t.Fatalf("imageInputDir() = %q, want %q", got, want)
}
}

func TestImageInputDirUsesStorageConfig(t *testing.T) {
cfg := config.Default()
cfg.Storage.ImageInputDir = filepath.Join(t.TempDir(), "custom-inputs")

got := imageInputDir(filepath.Join(t.TempDir(), "data"), cfg)

if got != cfg.Storage.ImageInputDir {
t.Fatalf("imageInputDir() = %q, want %q", got, cfg.Storage.ImageInputDir)
}
}

type blockingMainTestGenerator struct {
entered chan backend.GenerateRequest
release chan struct{}
Expand Down
1 change: 1 addition & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ server:
storage:
data_dir: "" # 服务数据目录;为空时使用系统用户数据目录
sqlite_path: "" # SQLite 数据库路径;为空时使用 data_dir/imgen.db
image_input_dir: "" # submit 图片暂存目录;为空时使用 data_dir/input_images

scheduler:
global_max_concurrency: 10 # serve 内底层生图队列总并发;submit async 和 WebSocket realtime 共用
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func handleCreateJob(w http.ResponseWriter, r *http.Request, service Service) {
if err != nil {
status := http.StatusInternalServerError
code := "internal_error"
if err.Error() == "prompt is required" || strings.HasPrefix(err.Error(), "image path not found:") {
if err.Error() == "prompt is required" || strings.HasPrefix(err.Error(), "image path not found") || strings.HasPrefix(err.Error(), "image path must ") {
status = http.StatusBadRequest
code = "invalid_argument"
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func (invalidPromptService) CancelJob(jobID string) error { return n
type invalidImageService struct{}

func (invalidImageService) CreateJob(req CreateJobRequest) (CreateJobResult, error) {
return CreateJobResult{}, errors.New("image path not found: /tmp/missing.png")
return CreateJobResult{}, errors.New("image path not found")
}
func (invalidImageService) GetJob(jobID string) (JobStatus, error) { return JobStatus{}, nil }
func (invalidImageService) ListJobs(limit int) ([]JobSummary, error) { return nil, nil }
Expand Down
76 changes: 74 additions & 2 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,95 @@ package cli

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"

api "github.com/walker1211/codex-imgen/internal/api"
)

type Client struct {
BaseURL string
BaseURL string
ImageInputDir string
}

func (c Client) CreateJob(ctx context.Context, prompt string, images []string, count int, concurrency int) (api.CreateJobResult, error) {
stagedImages, err := c.stageImages(images)
if err != nil {
return api.CreateJobResult{}, err
}
return api.Client{BaseURL: c.BaseURL}.CreateJob(ctx, api.CreateJobRequest{
Prompt: prompt,
Images: images,
Images: stagedImages,
Count: count,
Concurrency: concurrency,
})
}

func (c Client) stageImages(images []string) ([]string, error) {
if strings.TrimSpace(c.ImageInputDir) == "" {
return images, nil
}
staged := make([]string, 0, len(images))
for _, image := range images {
trimmed := strings.TrimSpace(image)
if trimmed == "" {
continue
}
name, err := c.stageImage(trimmed)
if err != nil {
return nil, err
}
staged = append(staged, name)
}
return staged, nil
}

func (c Client) stageImage(image string) (string, error) {
source, err := filepath.Abs(image)
if err != nil {
return "", err
}
info, err := os.Stat(source)
if err != nil {
if os.IsNotExist(err) {
return "", errors.New("image path not found")
}
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("image path must be a regular file")
}
if err := os.MkdirAll(c.ImageInputDir, 0o700); err != nil {
return "", err
}
content, err := os.ReadFile(source)
if err != nil {
return "", err
}
name := stagedImageName(source)
if err := os.WriteFile(filepath.Join(c.ImageInputDir, name), content, 0o600); err != nil {
return "", err
}
return name, nil
}

func stagedImageName(source string) string {
cleanPath := filepath.Clean(source)
base := filepath.Base(cleanPath)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
sum := sha256.Sum256([]byte(cleanPath))
hash := hex.EncodeToString(sum[:6])
if stem == "" {
return hash + ext
}
return stem + "-" + hash + ext
}

func (c Client) GetJob(ctx context.Context, jobID string) (api.JobStatus, error) {
return api.Client{BaseURL: c.BaseURL}.GetJob(ctx, jobID)
}
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

api "github.com/walker1211/codex-imgen/internal/api"
Expand Down Expand Up @@ -204,3 +206,34 @@ func TestClientSubmitRequestIncludesImages(t *testing.T) {
t.Fatalf("CreateJob returned error: %v", err)
}
}

func TestClientSubmitStagesImagesIntoInputDir(t *testing.T) {
source := filepath.Join(t.TempDir(), "ref.png")
if err := os.WriteFile(source, []byte("png"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
inputDir := t.TempDir()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req api.CreateJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("Decode returned error: %v", err)
}
if len(req.Images) != 1 {
t.Fatalf("images = %v", req.Images)
}
if !filepath.IsLocal(req.Images[0]) {
t.Fatalf("image path should be relative to input dir: %v", req.Images)
}
if _, err := os.Stat(filepath.Join(inputDir, req.Images[0])); err != nil {
t.Fatalf("staged image missing: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "data": map[string]any{"job_id": "job_123", "status": "queued"}})
}))
defer server.Close()

_, err := (&Client{BaseURL: server.URL, ImageInputDir: inputDir}).CreateJob(context.Background(), "draw a dragon", []string{source}, 1, 1)
if err != nil {
t.Fatalf("CreateJob returned error: %v", err)
}
}
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ type ServerConfig struct {
}

type StorageConfig struct {
DataDir string `yaml:"data_dir"`
SQLitePath string `yaml:"sqlite_path"`
DataDir string `yaml:"data_dir"`
SQLitePath string `yaml:"sqlite_path"`
ImageInputDir string `yaml:"image_input_dir"`
}

type SchedulerConfig struct {
Expand Down
1 change: 1 addition & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func Load(path string) (Config, error) {
}
cfg.Storage.DataDir = expandHome(cfg.Storage.DataDir)
cfg.Storage.SQLitePath = expandHome(cfg.Storage.SQLitePath)
cfg.Storage.ImageInputDir = expandHome(cfg.Storage.ImageInputDir)
cfg.Backend.CWD = expandHome(cfg.Backend.CWD)
cfg.Backend.DeliveryDir = expandHome(cfg.Backend.DeliveryDir)
applyEnv(&cfg)
Expand Down
23 changes: 23 additions & 0 deletions internal/config/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,29 @@ func TestLoadParsesSchedulerJobConcurrencyFields(t *testing.T) {
}
}

func TestLoadParsesStorageImageInputDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := []byte(`storage:
image_input_dir: ~/imgen-inputs
`)
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir returned error: %v", err)
}
want := filepath.Join(home, "imgen-inputs")
if cfg.Storage.ImageInputDir != want {
t.Fatalf("storage.image_input_dir = %q, want %q", cfg.Storage.ImageInputDir, want)
}
}

func TestLoadParsesBackendDeliveryDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := []byte(`backend:
Expand Down
36 changes: 27 additions & 9 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Service struct {
DefaultJobConcurrency int
MaxJobConcurrency int
MaxCountPerJob int
ImageInputDir string
Now func() time.Time
RetryDelays []time.Duration
MaxAttempts int
Expand Down Expand Up @@ -90,23 +91,40 @@ func (s *Service) publish(event notify.Event) {
}
}

func normalizeImagePaths(paths []string) ([]string, error) {
func normalizeImagePaths(paths []string, inputDir string) ([]string, error) {
root := strings.TrimSpace(inputDir)
if root == "" {
var err error
root, err = os.Getwd()
if err != nil {
return nil, err
}
}
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}

var result []string
for _, path := range paths {
trimmed := strings.TrimSpace(path)
for _, imagePath := range paths {
trimmed := strings.TrimSpace(imagePath)
if trimmed == "" {
continue
}
abs, err := filepath.Abs(trimmed)
if err != nil {
return nil, err
if !filepath.IsLocal(trimmed) {
return nil, errors.New("image path must stay within image input directory")
}
if _, err := os.Stat(abs); err != nil {
abs := filepath.Join(root, filepath.Clean(trimmed))
info, err := os.Stat(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("image path not found: %s", abs)
return nil, errors.New("image path not found")
}
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("image path must be a regular file")
}
result = append(result, abs)
}
return result, nil
Expand Down Expand Up @@ -141,7 +159,7 @@ func (s *Service) CreateJob(req api.CreateJobRequest) (api.CreateJobResult, erro
if strings.TrimSpace(req.Prompt) == "" {
return api.CreateJobResult{}, errors.New("prompt is required")
}
normalizedImages, err := normalizeImagePaths(req.Images)
normalizedImages, err := normalizeImagePaths(req.Images, s.ImageInputDir)
if err != nil {
return api.CreateJobResult{}, err
}
Expand Down
Loading