Measured against the instruction-edit models on 2026-07-31: asking a diffusion model to put a SPECIFIC person's face into a photo does not work by any route. qwen-image-edit returns the picture essentially unchanged whether asked by name, by attribute, or by supplying the portrait as a second reference image; flux-kontext replaces the face with a different generic person. Identity transfer is a detect/align/blend pipeline, not a better prompt, so it gets its own interface rather than more Edit options. imagegen.FaceSwapper is optional and type-asserted, like Editor — a provider that cannot do this must not have Edit quietly stand in for it. ListFaces is part of the interface, not a convenience: a caller asked to change "the man on the right" needs a stable way to NAME one face, and pixel boxes let it check its own choice. The llamaswap shim orders faces left to right for exactly that reason (insightface's own order is score-ranked and unstable between near-identical images), and a malformed box is a protocol error rather than a zero-filled struct, because a wrong box aims the swap at the wrong person. The provider is the first here to POST more than one file, so buildMultipart gained buildMultipartFiles and now delegates to it — one writer loop, so the two cannot drift in how they escape names or terminate the body. index and all are mutually exclusive ON THE WIRE: the shim ignores index under all=true, and sending both would imply a precedence the caller cannot see. A JSON body is refused rather than returned as image bytes — the shim answers JSON on a semantic miss (no face in the source), and handing that back as a picture would report success while delivering a file that is not one.
215 lines
6.9 KiB
Go
215 lines
6.9 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
func swapImg(t *testing.T) imagegen.Image {
|
|
t.Helper()
|
|
raw, err := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
if err != nil {
|
|
t.Fatalf("decode fixture: %v", err)
|
|
}
|
|
return imagegen.Image{MIME: "image/png", Data: raw}
|
|
}
|
|
|
|
// parseParts pulls the multipart form a handler received.
|
|
func parseParts(t *testing.T, r *http.Request) (files map[string][]byte, fields map[string]string) {
|
|
t.Helper()
|
|
_, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
t.Fatalf("content type: %v", err)
|
|
}
|
|
mr := multipart.NewReader(r.Body, params["boundary"])
|
|
files, fields = map[string][]byte{}, map[string]string{}
|
|
for {
|
|
p, err := mr.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("next part: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(p)
|
|
if p.FileName() != "" {
|
|
files[p.FormName()] = body
|
|
} else {
|
|
fields[p.FormName()] = string(body)
|
|
}
|
|
}
|
|
return files, fields
|
|
}
|
|
|
|
// TestFaceSwapSendsBothFiles pins the two-file wire shape. A face swap is the
|
|
// first endpoint in this provider taking more than one file, so buildMultipart
|
|
// grew a sibling; getting the field NAMES wrong would reach the shim as a
|
|
// missing-argument 422 rather than anything self-explanatory.
|
|
func TestFaceSwapSendsBothFiles(t *testing.T) {
|
|
var gotPath string
|
|
var files map[string][]byte
|
|
var fields map[string]string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
files, fields = parseParts(t, r)
|
|
w.Header().Set("Content-Type", "image/png")
|
|
raw, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
_, _ = w.Write(raw)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
m, err := p.FaceSwapModel("faceswap")
|
|
if err != nil {
|
|
t.Fatalf("model: %v", err)
|
|
}
|
|
img := swapImg(t)
|
|
res, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img},
|
|
imagegen.WithFaceIndex(2))
|
|
if err != nil {
|
|
t.Fatalf("faceswap: %v", err)
|
|
}
|
|
if len(res.Images) != 1 {
|
|
t.Fatalf("images = %d, want 1", len(res.Images))
|
|
}
|
|
if !strings.HasSuffix(gotPath, "/upstream/faceswap/v1/faceswap") {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
for _, want := range []string{"target", "source"} {
|
|
if len(files[want]) == 0 {
|
|
t.Errorf("no %q file part — the shim requires both", want)
|
|
}
|
|
}
|
|
if fields["index"] != "2" {
|
|
t.Errorf("index = %q, want 2", fields["index"])
|
|
}
|
|
if _, ok := fields["all"]; ok {
|
|
t.Error("all sent alongside index — the shim ignores index under all=true, so sending both implies a precedence the caller cannot see")
|
|
}
|
|
}
|
|
|
|
// TestFaceSwapAllSuppressesIndex: same reasoning from the other side.
|
|
func TestFaceSwapAllSuppressesIndex(t *testing.T) {
|
|
var fields map[string]string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, fields = parseParts(t, r)
|
|
w.Header().Set("Content-Type", "image/png")
|
|
raw, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
_, _ = w.Write(raw)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
m, _ := p.FaceSwapModel("faceswap")
|
|
img := swapImg(t)
|
|
if _, err := m.FaceSwap(context.Background(),
|
|
imagegen.FaceSwapRequest{Target: img, Source: img, Index: new(int), All: true}); err != nil {
|
|
t.Fatalf("faceswap: %v", err)
|
|
}
|
|
if fields["all"] != "true" {
|
|
t.Errorf("all = %q, want true", fields["all"])
|
|
}
|
|
if _, ok := fields["index"]; ok {
|
|
t.Error("index sent under all=true")
|
|
}
|
|
}
|
|
|
|
// TestFaceSwapRejectsMissingImages: both are required, and the error should
|
|
// name which one rather than surfacing a shim 422.
|
|
func TestFaceSwapRejectsMissingImages(t *testing.T) {
|
|
p := New(WithBaseURL("http://example.invalid"))
|
|
m, _ := p.FaceSwapModel("faceswap")
|
|
img := swapImg(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
req imagegen.FaceSwapRequest
|
|
want string
|
|
}{
|
|
{"no target", imagegen.FaceSwapRequest{Source: img}, "target"},
|
|
{"no source", imagegen.FaceSwapRequest{Target: img}, "source"},
|
|
} {
|
|
_, err := m.FaceSwap(context.Background(), tc.req)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("%s: err = %v, want one naming %q", tc.name, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFaceSwapRejectsNonImageResponse: the shim answers JSON on a semantic
|
|
// miss (no face found). Returning those bytes as an "image" would hand the
|
|
// caller a file that is not a picture and call it success.
|
|
func TestFaceSwapRejectsNonImageResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"detail":{"error":"no_face_in_source"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
m, _ := p.FaceSwapModel("faceswap")
|
|
img := swapImg(t)
|
|
_, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img})
|
|
if err == nil {
|
|
t.Fatal("a JSON body was accepted as an image")
|
|
}
|
|
var apiErr *llm.APIError
|
|
if !strings.Contains(err.Error(), "no_face_in_source") {
|
|
t.Errorf("err = %v, want it to relay the shim's reason", err)
|
|
}
|
|
_ = apiErr
|
|
}
|
|
|
|
// TestListFacesParsesOrdering: the shim's left-to-right index is the contract
|
|
// callers select against, so it must survive decoding intact.
|
|
func TestListFacesParsesOrdering(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasSuffix(r.URL.Path, "/upstream/faceswap/v1/faces") {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"count":2,"faces":[
|
|
{"index":0,"box":[10,20,30,40],"score":0.9,"width":20,"height":20},
|
|
{"index":1,"box":[50,20,90,60],"score":0.8,"width":40,"height":40}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
m, _ := p.FaceSwapModel("faceswap")
|
|
faces, err := m.ListFaces(context.Background(), swapImg(t))
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(faces) != 2 || faces[0].Index != 0 || faces[1].Index != 1 {
|
|
t.Fatalf("faces = %+v", faces)
|
|
}
|
|
if faces[1].Box != [4]int{50, 20, 90, 60} {
|
|
t.Errorf("box = %v", faces[1].Box)
|
|
}
|
|
}
|
|
|
|
// TestListFacesRejectsShortBox: a malformed box would send a caller at the
|
|
// wrong face, which is worse than an error.
|
|
func TestListFacesRejectsShortBox(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"count":1,"faces":[{"index":0,"box":[1,2],"score":0.9}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
m, _ := p.FaceSwapModel("faceswap")
|
|
if _, err := m.ListFaces(context.Background(), swapImg(t)); err == nil {
|
|
t.Fatal("a 2-element box was accepted")
|
|
}
|
|
}
|