Add multipart image edit support

This commit is contained in:
2026-06-08 01:17:42 +08:00
parent b7500d81d1
commit 679bfeb9c9
9 changed files with 669 additions and 27 deletions
@@ -1,10 +1,12 @@
package httpapi
import (
"bytes"
"context"
"encoding/base64"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
@@ -75,6 +77,87 @@ func TestCanonicalConversationMessageHashUsesTextAndAssetRefs(t *testing.T) {
}
}
func TestImageEditMultipartFormBodyMapsFilesAndFields(t *testing.T) {
var raw bytes.Buffer
writer := multipart.NewWriter(&raw)
if err := writer.WriteField("model", "doubao-5.0图像编辑"); err != nil {
t.Fatalf("write model field: %v", err)
}
if err := writer.WriteField("prompt", "换个姿势"); err != nil {
t.Fatalf("write prompt field: %v", err)
}
if err := writer.WriteField("n", "2"); err != nil {
t.Fatalf("write n field: %v", err)
}
if err := writer.WriteField("sequential_image_generation_options", `{"max_images":2}`); err != nil {
t.Fatalf("write sequential options field: %v", err)
}
writeMultipartFixtureFile(t, writer, "image", "single.png")
writeMultipartFixtureFile(t, writer, "images", "ref-a.png")
writeMultipartFixtureFile(t, writer, "images[]", "ref-b.png")
writeMultipartFixtureFile(t, writer, "mask", "mask.png")
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/images/edits", &raw)
request.Header.Set("Content-Type", writer.FormDataContentType())
if err := request.ParseMultipartForm(multipartTaskMemoryBytes); err != nil {
t.Fatalf("parse multipart form: %v", err)
}
defer request.MultipartForm.RemoveAll()
body, err := imageEditMultipartFormBody(context.Background(), request.MultipartForm, func(_ context.Context, field string, header *multipart.FileHeader) (map[string]any, error) {
ref := map[string]any{
"sha256": field + "-" + header.Filename,
"url": "https://cdn.example/" + header.Filename,
"contentType": header.Header.Get("Content-Type"),
"storageProvider": "server_main_openapi",
}
return requestAssetWrapper(ref), nil
})
if err != nil {
t.Fatalf("build multipart image edit body: %v", err)
}
if body["model"] != "doubao-5.0图像编辑" || body["prompt"] != "换个姿势" {
t.Fatalf("unexpected scalar fields: %+v", body)
}
if body["n"] != float64(2) {
t.Fatalf("n should be parsed as number, got %#v", body["n"])
}
options, _ := body["sequential_image_generation_options"].(map[string]any)
if options["max_images"] != float64(2) {
t.Fatalf("sequential options should parse JSON object, got %+v", options)
}
image, _ := body["image"].(map[string]any)
if image["url"] != "https://cdn.example/single.png" {
t.Fatalf("single image should map to image URL wrapper, got %+v", image)
}
images, _ := body["images"].([]any)
if len(images) != 2 {
t.Fatalf("multi image fields should map to images array, got %+v", body["images"])
}
firstMulti, _ := images[0].(map[string]any)
secondMulti, _ := images[1].(map[string]any)
if firstMulti["url"] != "https://cdn.example/ref-a.png" || secondMulti["url"] != "https://cdn.example/ref-b.png" {
t.Fatalf("unexpected images array: %+v", images)
}
mask, _ := body["mask"].(map[string]any)
if mask["url"] != "https://cdn.example/mask.png" {
t.Fatalf("mask should map to mask URL wrapper, got %+v", mask)
}
}
func writeMultipartFixtureFile(t *testing.T, writer *multipart.Writer, field string, filename string) {
t.Helper()
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create multipart file %s/%s: %v", field, filename, err)
}
if _, err := part.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}); err != nil {
t.Fatalf("write multipart file %s/%s: %v", field, filename, err)
}
}
func TestCleanupExpiredLocalTempAssetsDeletesExpiredStaticFiles(t *testing.T) {
uploadedDir := t.TempDir()
generatedDir := t.TempDir()