Keep Gemini image edits role-aware on the server path

The server now resolves source, identity, and style artifacts separately, emits role-labeled Gemini parts, and logs the effective request shape so deployed debugging can confirm which references reached upstream.

Constraint: The test deployment must stay on gemini-3.1-flash-image-preview while gaining better multi-image editing semantics
Rejected: Change the default model to gemini-3-pro-image-preview | test-server parity required keeping flash as default
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve the source/identity/style logging until the upstream gateway exposes equivalent structured traces
Tested: go test ./internal/server
Tested: image img2img against http://101.42.99.35:18080/v1 (job_5af212df2c27)
Not-tested: Non-Gemini multi-image providers still fall back to the first reference only
This commit is contained in:
jiajia
2026-04-16 18:49:05 +08:00
parent 531ffc6ee8
commit dcd21746d2
3 changed files with 247 additions and 38 deletions
+73 -6
View File
@@ -52,11 +52,11 @@ func TestGenerateGeminiImageRefsUsesGenerateContentEndpoint(t *testing.T) {
refs, usage, err := client.generateEditedImageRefs(context.Background(), "sk-test", "gemini-3-pro-image-preview", map[string]any{
"prompt": "edit this image",
"size": "1024x1536",
}, imageEditReference{
}, []imageEditReference{{
Filename: "source.png",
ContentType: "image/png",
Content: refBytes,
})
}})
if err != nil {
t.Fatalf("generateEditedImageRefs: %v", err)
}
@@ -185,12 +185,12 @@ func TestGenerateEditedImageRefsUsesSeedreamImagesGenerationsEndpoint(t *testing
refs, usage, err := client.generateEditedImageRefs(context.Background(), "sk-test", "seedream-4-5-251128", map[string]any{
"prompt": "edit this image into a dusk scene",
"size": "2K",
}, imageEditReference{
}, []imageEditReference{{
Filename: "source.jpg",
ContentType: "image/jpeg",
Content: []byte("binary-image"),
URL: "https://example.com/reference.jpg",
})
}})
if err != nil {
t.Fatalf("generateEditedImageRefs: %v", err)
}
@@ -347,9 +347,9 @@ func TestGenerateMiniMaxEditedImageRefsSendsStandardImageField(t *testing.T) {
"prompt": "turn it into watercolor",
"size": "832x1248",
"response_format": "url",
}, imageEditReference{
}, []imageEditReference{{
URL: "https://example.com/reference.jpg",
})
}})
if err != nil {
t.Fatalf("generateEditedImageRefs: %v", err)
}
@@ -360,3 +360,70 @@ func TestGenerateMiniMaxEditedImageRefsSendsStandardImageField(t *testing.T) {
t.Fatalf("expected size passthrough for minimax mapping, got %#v", gotBody["size"])
}
}
func TestGenerateGeminiImageRefsAnnotatesMultiImageRoles(t *testing.T) {
const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z7xkAAAAASUVORK5CYII="
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"candidates": [{
"content": {
"parts": [{
"inlineData": {
"mimeType": "image/png",
"data": "` + png1x1 + `"
}
}]
}
}]
}`))
}))
defer srv.Close()
refBytes, err := base64.StdEncoding.DecodeString(png1x1)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
client := &newAPIClient{
baseURL: strings.TrimRight(srv.URL, "/"),
httpClient: srv.Client(),
}
_, _, err = client.generateGeminiImageRefs(context.Background(), "sk-test", "gemini-3-pro-image-preview", map[string]any{
"prompt": "Replace the person in Image 1 with the character from Image 2 and apply the style from Image 3.",
}, []imageEditReference{
{Role: "source", Filename: "source.png", ContentType: "image/png", Content: refBytes},
{Role: "identity", Filename: "identity.png", ContentType: "image/png", Content: refBytes},
{Role: "style", Filename: "style.png", ContentType: "image/png", Content: refBytes},
})
if err != nil {
t.Fatalf("generateGeminiImageRefs: %v", err)
}
contents, ok := gotBody["contents"].([]any)
if !ok || len(contents) != 1 {
t.Fatalf("unexpected contents: %#v", gotBody["contents"])
}
content, ok := contents[0].(map[string]any)
if !ok {
t.Fatalf("unexpected content entry: %#v", contents[0])
}
parts, ok := content["parts"].([]any)
if !ok || len(parts) != 7 {
t.Fatalf("expected prompt plus three labeled images, got %#v", content["parts"])
}
if got := parts[1].(map[string]any)["text"]; got != "Image 1 is the source scene. Keep the scene layout, camera framing, main action, and spatial relationships from this image." {
t.Fatalf("unexpected source label: %#v", got)
}
if got := parts[3].(map[string]any)["text"]; got != "Image 2 is the identity reference. Keep the character face, hair, accessories, and recognizability from this image." {
t.Fatalf("unexpected identity label: %#v", got)
}
if got := parts[5].(map[string]any)["text"]; got != "Image 3 is the style reference. Apply only the visual style, palette, texture, and illustration treatment from this image. Do not change the character identity because of this image." {
t.Fatalf("unexpected style label: %#v", got)
}
}