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
1 change: 1 addition & 0 deletions backend/internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ func GenerateWithImagesHandler(c *gin.Context) {
"model_id": modelID,
"aspect_ratio": req.AspectRatio,
"resolution_level": req.ImageSize,
"quality": req.Quality,
"count": req.Count,
"reference_images": refImageBytes, // 传递 interface 列表,方便 Provider 类型断言
}
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/api/multipart_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type MultipartRequest struct {
Prompt string
AspectRatio string
ImageSize string
Quality string
Count int
Verbose bool
PromptOptimizeMode string
Expand Down Expand Up @@ -86,6 +87,14 @@ func ParseGenerateRequestFromMultipart(c *gin.Context) (*MultipartRequest, error
req.ImageSize = string(data)
return nil
})
p.Parser.Register("quality", func(reader io.Reader, header formstream.Header) error {
data, err := io.ReadAll(reader)
if err != nil {
return err
}
req.Quality = string(data)
return nil
})
p.Parser.Register("count", func(reader io.Reader, header formstream.Header) error {
data, err := io.ReadAll(reader)
if err != nil {
Expand Down Expand Up @@ -172,6 +181,7 @@ func parseWithStandardLibrary(c *gin.Context) (*MultipartRequest, error) {
Prompt: c.PostForm("prompt"),
AspectRatio: c.PostForm("aspectRatio"),
ImageSize: c.PostForm("imageSize"),
Quality: c.PostForm("quality"),
Count: 1,
}

Expand Down
49 changes: 49 additions & 0 deletions backend/internal/api/multipart_helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package api

import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

func TestParseGenerateRequestFromMultipartIncludesQuality(t *testing.T) {
gin.SetMode(gin.TestMode)

var body bytes.Buffer
writer := multipart.NewWriter(&body)
fields := map[string]string{
"provider": "openai-image",
"model_id": "gpt-image-2",
"prompt": "edit prompt",
"aspectRatio": "1:1",
"imageSize": "2K",
"quality": "high",
"count": "1",
}
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
t.Fatalf("WriteField %s: %v", key, err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("Close multipart writer: %v", err)
}

request := httptest.NewRequest(http.MethodPost, "/api/generate-with-images", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = request

req, err := ParseGenerateRequestFromMultipart(c)
if err != nil {
t.Fatalf("ParseGenerateRequestFromMultipart: %v", err)
}
if req.Quality != "high" {
t.Fatalf("Quality = %q, want high", req.Quality)
}
}
2 changes: 2 additions & 0 deletions backend/internal/api/task_timeout_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func normalizeProviderForTimeout(providerName string) string {
switch {
case strings.HasPrefix(name, "gemini"):
return "gemini"
case name == "openai-image":
return "openai-image"
case strings.HasPrefix(name, "openai"):
return "openai"
default:
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/api/task_timeout_reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package api

import (
"testing"
"time"
)

func TestTaskTimeoutForProviderKeepsOpenAIImageConfig(t *testing.T) {
timeoutMap := map[string]time.Duration{
"openai": 150 * time.Second,
"openai-image": 500 * time.Second,
}

if got := normalizeProviderForTimeout("openai-image"); got != "openai-image" {
t.Fatalf("normalizeProviderForTimeout(openai-image) = %q, want openai-image", got)
}
if got := taskTimeoutForProvider("openai-image", timeoutMap); got != 500*time.Second {
t.Fatalf("openai-image timeout = %s, want 500s", got)
}
if got := taskTimeoutForProvider("openai-chat", timeoutMap); got != 150*time.Second {
t.Fatalf("openai-chat timeout = %s, want 150s", got)
}
}
31 changes: 26 additions & 5 deletions backend/internal/provider/openai_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image-gen-service/internal/diagnostic"
"image-gen-service/internal/model"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"math"
"mime/multipart"
Expand All @@ -17,6 +21,8 @@ import (
"strconv"
"strings"
"time"

_ "golang.org/x/image/webp"
)

type OpenAIImageProvider struct {
Expand Down Expand Up @@ -414,20 +420,35 @@ func collectOpenAIImageReferences(raw interface{}) ([]openAIImageReference, erro
if len(imgBytes) == 0 {
continue
}
mimeType := http.DetectContentType(imgBytes)
if mimeType != "image/png" {
return nil, fmt.Errorf("第 %d 张参考图不是有效的 PNG 图片,OpenAI Edit 仅支持 PNG 格式", idx+1)
pngBytes, err := normalizeOpenAIReferenceImagePNG(imgBytes)
if err != nil {
return nil, fmt.Errorf("第 %d 张参考图不是有效图片: %w", idx+1, err)
}
refs = append(refs, openAIImageReference{
Name: fmt.Sprintf("reference-%d.png", idx+1),
Content: imgBytes,
MIME: mimeType,
Content: pngBytes,
MIME: "image/png",
})
}

return refs, nil
}

func normalizeOpenAIReferenceImagePNG(imgBytes []byte) ([]byte, error) {
if http.DetectContentType(imgBytes) == "image/png" {
return imgBytes, nil
}
img, _, err := image.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

func escapeMultipartFilename(name string) string {
return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(name)
}
Expand Down
64 changes: 59 additions & 5 deletions backend/internal/provider/openai_image_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package provider

import (
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image-gen-service/internal/model"
"image/color"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -188,6 +193,7 @@ func TestOpenAIImageProviderGenerateWithReferenceUsesEdits(t *testing.T) {
"model_id": "gpt-image-2-all",
"aspect_ratio": "1:1",
"resolution_level": "1K",
"quality": "high",
"count": 1,
"reference_images": []interface{}{refBytes, refBytes, refBytes},
})
Expand All @@ -200,6 +206,9 @@ func TestOpenAIImageProviderGenerateWithReferenceUsesEdits(t *testing.T) {
if seenFields["model"] != "gpt-image-2-all" || seenFields["prompt"] != "edit prompt" || seenFields["size"] != "1280x1280" {
t.Fatalf("unexpected fields: %+v", seenFields)
}
if seenFields["quality"] != "high" {
t.Fatalf("quality = %q, want high", seenFields["quality"])
}
if _, ok := seenFields["input_fidelity"]; ok {
t.Fatalf("input_fidelity should not be sent to edits: %+v", seenFields)
}
Expand All @@ -214,10 +223,45 @@ func TestOpenAIImageProviderGenerateWithReferenceUsesEdits(t *testing.T) {
}
}

func TestOpenAIImageProviderRejectsNonPNGReference(t *testing.T) {
func TestOpenAIImageProviderConvertsJPEGReferenceToPNG(t *testing.T) {
var jpegRef bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.White)
if err := jpeg.Encode(&jpegRef, img, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}

var seenImageContentType string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(2 << 20); err != nil {
t.Fatalf("ParseMultipartForm: %v", err)
}
files := r.MultipartForm.File["image"]
if len(files) != 1 {
t.Fatalf("image files = %d, want 1", len(files))
}
seenImageContentType = files[0].Header.Get("Content-Type")
file, err := files[0].Open()
if err != nil {
t.Fatalf("open image part: %v", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read image part: %v", err)
}
if http.DetectContentType(content) != "image/png" {
t.Fatalf("uploaded content type = %q, want image/png", http.DetectContentType(content))
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]string{{"b64_json": tinyPNGBase64}},
})
}))
defer server.Close()

p, err := NewOpenAIImageProvider(&model.ProviderConfig{
ProviderName: "openai-image",
APIBase: "http://example.test/v1",
APIBase: server.URL,
APIKey: "test-key",
TimeoutSeconds: 5,
})
Expand All @@ -231,9 +275,19 @@ func TestOpenAIImageProviderRejectsNonPNGReference(t *testing.T) {
"aspect_ratio": "1:1",
"resolution_level": "1K",
"count": 1,
"reference_images": []interface{}{[]byte("not an image")},
"reference_images": []interface{}{jpegRef.Bytes()},
})
if err == nil || !strings.Contains(err.Error(), "OpenAI Edit 仅支持 PNG 格式") {
t.Fatalf("Generate error = %v, want PNG validation error", err)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if seenImageContentType != "image/png" {
t.Fatalf("image part Content-Type = %q, want image/png", seenImageContentType)
}
}

func TestOpenAIImageProviderRejectsInvalidReference(t *testing.T) {
_, err := collectOpenAIImageReferences([]interface{}{[]byte("not an image")})
if err == nil || !strings.Contains(err.Error(), "不是有效图片") {
t.Fatalf("collectOpenAIImageReferences error = %v, want invalid image error", err)
}
}
4 changes: 3 additions & 1 deletion backend/internal/worker/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,15 @@ func fetchProviderTimeout(providerName string) time.Duration {
name := strings.TrimSpace(strings.ToLower(providerName))
if strings.HasPrefix(name, "gemini") {
name = "gemini"
} else if name == "openai-image" {
name = "openai-image"
} else if strings.HasPrefix(name, "openai") {
name = "openai"
}

defaultTimeout := func(p string) time.Duration {
switch p {
case "gemini", "openai":
case "gemini", "openai", "openai-image":
return 500 * time.Second
default:
return 150 * time.Second
Expand Down
44 changes: 44 additions & 0 deletions backend/internal/worker/pool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package worker

import (
"testing"
"time"

"image-gen-service/internal/model"

"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

func TestFetchProviderTimeoutKeepsOpenAIImageConfig(t *testing.T) {
originalDB := model.DB
t.Cleanup(func() {
model.DB = originalDB
})

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open test database: %v", err)
}
if err := db.AutoMigrate(&model.ProviderConfig{}); err != nil {
t.Fatalf("migrate provider config: %v", err)
}
model.DB = db

configs := []model.ProviderConfig{
{ProviderName: "openai", TimeoutSeconds: 150},
{ProviderName: "openai-image", TimeoutSeconds: 500},
}
for _, cfg := range configs {
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create provider config %s: %v", cfg.ProviderName, err)
}
}

if got := fetchProviderTimeout("openai-image"); got != 500*time.Second {
t.Fatalf("openai-image timeout = %s, want 500s", got)
}
if got := fetchProviderTimeout("openai"); got != 150*time.Second {
t.Fatalf("openai timeout = %s, want 150s", got)
}
}
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nano-banana-pro-frontend",
"private": true,
"version": "2.8.1",
"version": "2.8.2",
"license": "MIT",
"description": "大香蕉图片生成工具 - 批量图片生成应用前端",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "desktop"
version = "2.8.1"
version = "2.8.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
Expand Down
Loading
Loading