From 551ed007e698c6cd51fb5c1a9d4fb2bdfcd5ba10 Mon Sep 17 00:00:00 2001 From: jbergner Date: Mon, 10 Aug 2026 16:32:07 +0200 Subject: [PATCH] RC-5 --- README.md | 6 ++++++ internal/artifact/collection_test.go | 14 +++++++++++--- internal/artifact/openai.go | 29 +++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b2d91ea..f64c36d 100644 --- a/README.md +++ b/README.md @@ -638,3 +638,9 @@ POST /api/admin/tasks/ensure ## V2.9: lokaler RIFT-Pipeline-Test Im Adminbereich unter **TASK ACTIONS** kann für den ausgewählten Task eine komplette lokale Testkarte erzeugt werden. Der Test verwendet den vorhandenen `character_anchor.png` als Mock-Character-Artwork und die Style-Referenz des Tasks als Hintergrund, führt die deterministische Trait-Auswahl, das programmatische Kartenlayout und das Schreiben der Testdateien aus, ruft aber **keine externe Bild-API** auf. Die Dateien landen unter `data/artifacts/_test//`; der echte Task- und Artifact-Status bleibt unverändert. + + +### OpenAI reference-image MIME handling + +Reference images sent to the OpenAI Images Edit endpoint are uploaded with an explicit per-part MIME type (`image/png`, `image/jpeg` or `image/webp`). This is required because Go's `multipart.CreateFormFile` otherwise labels file parts as `application/octet-stream`, which the Images API rejects. The character anchor is always sent as `image/png`; task style references preserve their detected image MIME type. + diff --git a/internal/artifact/collection_test.go b/internal/artifact/collection_test.go index 7db2fca..34d8aec 100644 --- a/internal/artifact/collection_test.go +++ b/internal/artifact/collection_test.go @@ -120,6 +120,9 @@ func TestOpenAIRequestSendsReferenceAsImageEdit(t *testing.T) { if len(files) != 1 { t.Fatalf("expected one reference image, got %d", len(files)) } + if got := files[0].Header.Get("Content-Type"); got != "image/png" { + t.Fatalf("reference content type = %q, want image/png", got) + } f, err := files[0].Open() if err != nil { t.Fatal(err) @@ -141,7 +144,7 @@ func TestOpenAIRequestSendsReferenceAsImageEdit(t *testing.T) { cfg.ArtifactWidth = 1024 cfg.ArtifactHeight = 1536 cfg.ArtifactQuality = "medium" - res, err := w.openAIRequest(context.Background(), cfg, "test prompt", []referenceImage{{Name: "reference.png", Bytes: []byte("reference-bytes")}}) + res, err := w.openAIRequest(context.Background(), cfg, "test prompt", []referenceImage{{Name: "reference.png", ContentType: "image/png", Bytes: []byte("reference-bytes")}}) if err != nil { t.Fatal(err) } @@ -204,6 +207,7 @@ func TestTaskStyleReferenceDefaultsAndCustom(t *testing.T) { func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) { var names []string + var contentTypes []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(4 << 20); err != nil { t.Fatalf("parse multipart: %v", err) @@ -214,6 +218,7 @@ func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) { } for _, f := range files { names = append(names, f.Filename) + contentTypes = append(contentTypes, f.Header.Get("Content-Type")) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, base64.StdEncoding.EncodeToString([]byte("generated"))) @@ -224,8 +229,8 @@ func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) { w := &Worker{http: ts.Client()} cfg := settings.Defaults() _, err := w.openAIRequest(context.Background(), cfg, "prompt", []referenceImage{ - {Name: "character_anchor.png", Bytes: []byte("anchor")}, - {Name: "task_style.jpg", Bytes: []byte("style")}, + {Name: "character_anchor.png", ContentType: "image/png", Bytes: []byte("anchor")}, + {Name: "task_style.jpg", ContentType: "image/jpeg", Bytes: []byte("style")}, }) if err != nil { t.Fatal(err) @@ -233,4 +238,7 @@ func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) { if strings.Join(names, ",") != "character_anchor.png,task_style.jpg" { t.Fatalf("unexpected reference order: %v", names) } + if strings.Join(contentTypes, ",") != "image/png,image/jpeg" { + t.Fatalf("unexpected reference content types: %v", contentTypes) + } } diff --git a/internal/artifact/openai.go b/internal/artifact/openai.go index 6d398a4..70ce380 100644 --- a/internal/artifact/openai.go +++ b/internal/artifact/openai.go @@ -10,8 +10,10 @@ import ( "errors" "fmt" "io" + "mime" "mime/multipart" "net/http" + "net/textproto" "os" "path/filepath" "strings" @@ -21,8 +23,9 @@ import ( ) type referenceImage struct { - Name string - Bytes []byte + Name string + ContentType string + Bytes []byte } var collectionAnchorMu sync.Mutex @@ -130,8 +133,8 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt return imageResult{}, err } res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{ - {Name: "character_anchor.png", Bytes: anchor}, - {Name: styleRef.Name, Bytes: styleRef.Bytes}, + {Name: "character_anchor.png", ContentType: "image/png", Bytes: anchor}, + {Name: styleRef.Name, ContentType: styleRef.ContentType, Bytes: styleRef.Bytes}, }) if err != nil { return imageResult{}, err @@ -198,7 +201,23 @@ func (w *Worker) openAIRequest(ctx context.Context, cfg settings.Runtime, prompt if name == "" { name = fmt.Sprintf("reference_%d.png", i+1) } - part, err := mw.CreateFormFile("image[]", name) + contentType := strings.TrimSpace(ref.ContentType) + if contentType == "" { + contentType = styleContentType(name, ref.Bytes) + } + switch contentType { + case "image/jpeg", "image/png", "image/webp": + default: + return imageResult{}, fmt.Errorf("unsupported reference image content type %q for %s", contentType, name) + } + + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{ + "name": "image[]", + "filename": name, + })) + header.Set("Content-Type", contentType) + part, err := mw.CreatePart(header) if err != nil { return imageResult{}, err }