feat(imagegen): face swap (identity transfer), a separate operation from Edit #23
@@ -55,9 +55,13 @@ type DetectedFace struct {
|
||||
Box [4]int
|
||||
// Score is the detector's confidence, 0-1.
|
||||
Score float64
|
||||
// Width and Height are the box dimensions, carried so a caller can pick
|
||||
// "the big face" without recomputing them.
|
||||
Width, Height int
|
||||
}
|
||||
|
|
||||
|
||||
// Size returns the box dimensions. Derived rather than stored: carrying
|
||||
// width/height alongside Box is two sources of truth for one fact, and the
|
||||
// pair can disagree after any transform.
|
||||
func (f DetectedFace) Size() (w, h int) {
|
||||
return f.Box[2] - f.Box[0], f.Box[3] - f.Box[1]
|
||||
}
|
||||
|
gitea-actions
commented
🟠 FaceSwapper lacks a paired FaceSwapProvider/ModelOption surface, unlike every other optional imagegen capability maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟠 **FaceSwapper lacks a paired FaceSwapProvider/ModelOption surface, unlike every other optional imagegen capability**
_maintainability · flagged by 1 model_
- **`imagegen/faceswap.go:65`** — `FaceSwapper` breaks the package's established optional-capability pattern. Every other optional surface in this package (`Segmenter`/`SegmentationProvider` in `segment.go`, `Colorizer`/`FaceRestorer`/`FaceRestoreProvider` in `restore.go`/`facerestore.go`, `BackgroundRemover`/`BackgroundRemovalProvider` and `Upscaler`/`UpscaleProvider` in `background.go`/`upscale.go`) ships a paired `XProvider` interface (`Name()` + `XModel(id string, opts ...XModelOption) (X, e…
<sub>🪰 Gadfly · advisory</sub>
|
||||
|
||||
// FaceSwapper is the optional face-transfer surface. Separate interface so
|
||||
|
||||
@@ -74,7 +74,7 @@ func (m *faceSwapModel) ListFaces(ctx context.Context, img imagegen.Image) ([]im
|
||||
}
|
||||
out := make([]imagegen.DetectedFace, 0, len(parsed.Faces))
|
||||
for _, f := range parsed.Faces {
|
||||
df := imagegen.DetectedFace{Index: f.Index, Score: f.Score, Width: f.Width, Height: f.Height}
|
||||
df := imagegen.DetectedFace{Index: f.Index, Score: f.Score}
|
||||
// A short box would silently index out of range below; treat a
|
||||
// malformed entry as a protocol error rather than zero-filling it,
|
||||
// because a wrong box sends the caller at the wrong face.
|
||||
@@ -97,7 +97,10 @@ func (m *faceSwapModel) FaceSwap(ctx context.Context, req imagegen.FaceSwapReque
|
||||
if len(req.Source.Data) == 0 {
|
||||
return nil, fmt.Errorf("%w: face swap requires a source image", llm.ErrUnsupported)
|
||||
}
|
||||
if req.Index != nil && *req.Index < 0 {
|
||||
// Only when it will actually be sent: under All the index is documented
|
||||
|
gitea-actions
commented
⚪ Negative Index rejected even when All=true, where index is documented as ignored correctness, error-handling · flagged by 2 models
🪰 Gadfly · advisory ⚪ **Negative Index rejected even when All=true, where index is documented as ignored**
_correctness, error-handling · flagged by 2 models_
- **`provider/llamaswap/faceswap.go:100-102`** (minor) — Confirmed: the negative-index check `if req.Index != nil && *req.Index < 0` runs unconditionally, before `req.All` is considered, even though `imagegen/faceswap.go:28` documents "`All` swaps every detected face and ignores `Index`." So `FaceSwapRequest{All: true, Index: &negativeIndex}` is rejected despite `Index` being irrelevant under `All`. Low real-world impact (nil is the normal unset state), but it's an inconsistency between the doc…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// as ignored, so rejecting a negative one there would fail a request that
|
||||
// is perfectly well formed.
|
||||
if !req.All && req.Index != nil && *req.Index < 0 {
|
||||
return nil, fmt.Errorf("%w: face index must be >= 0, got %d", llm.ErrUnsupported, *req.Index)
|
||||
}
|
||||
path, err := upstreamPath(m.id, "/v1/faceswap")
|
||||
@@ -130,20 +133,34 @@ func (m *faceSwapModel) FaceSwap(ctx context.Context, req imagegen.FaceSwapReque
|
||||
if len(raw) == 0 {
|
||||
|
gitea-actions
commented
🔴 JSON/non-image response with no Content-Type header is silently accepted and mislabeled image/png instead of rejected correctness, error-handling, maintainability, security · flagged by 2 models
🪰 Gadfly · advisory 🔴 **JSON/non-image response with no Content-Type header is silently accepted and mislabeled image/png instead of rejected**
_correctness, error-handling, maintainability, security · flagged by 2 models_
- `provider/llamaswap/faceswap.go:134` — The non-image-response guard (`respType != "" && !isImageContentType(respType)`) is bypassed whenever the upstream response omits a `Content-Type` header, because `doRaw` (audio.go:363) returns `resp.Header.Get("Content-Type")` which is `""` for a missing header. In that case the code falls through to `sniffImageMIME(raw)` (image.go:237-243), which defaults to `image/png` for any payload it can't identify as an image — so a JSON error body served without…
<sub>🪰 Gadfly · advisory</sub>
|
||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "face swap response contained no image"}
|
||||
}
|
||||
mime := sniffImageMIME(raw)
|
||||
if respType != "" && !isImageContentType(respType) {
|
||||
// A JSON error body sniffs as text, not image — say what came back
|
||||
// rather than handing the caller bytes that are not a picture.
|
||||
// Validate the BYTES, not the header. sniffImageMIME falls back to
|
||||
// image/png when detection is inconclusive, so trusting it here would
|
||||
// label a JSON error body as a PNG and return it as a successful image —
|
||||
// and a header check alone misses the case where the response carries no
|
||||
// Content-Type at all. The shim answers JSON on a semantic miss (no face
|
||||
// found), which is exactly the body that would sail through.
|
||||
detected := http.DetectContentType(raw)
|
||||
if !strings.HasPrefix(detected, "image/") {
|
||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
||||
Message: fmt.Sprintf("face swap response is not an image (Content-Type %q): %s", respType, truncateForError(raw))}
|
||||
Message: fmt.Sprintf("face swap response is not an image (sniffed %q, Content-Type %q): %s",
|
||||
detected, respType, truncateForError(raw))}
|
||||
}
|
||||
|
gitea-actions
commented
🟡 imageFilename near-duplicates initImageFilename (video.go) in the same package; should be shared maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **imageFilename near-duplicates initImageFilename (video.go) in the same package; should be shared**
_maintainability · flagged by 1 model_
- **`provider/llamaswap/faceswap.go:147` — `imageFilename` near-duplicates the existing `initImageFilename`.** `initImageFilename(mimeType)` at `video.go:141-154` is the same MIME→filename switch in the *same package* (jpeg/webp/png), and the new `imageFilename(mimeType, base)` is a strict generalization of it (parameterized prefix, plus gif/bmp). This is copy-paste that should be shared: `initImageFilename` could become `return imageFilename(mimeType, "frame")` (its default already resolves to…
<sub>🪰 Gadfly · advisory</sub>
|
||||
return &imagegen.Result{Images: []llm.ImagePart{{MIME: mime, Data: raw}}}, nil
|
||||
// Prefer the server's own label when it is an image type (it knows
|
||||
// subtypes the sniffer does not), else what the bytes actually are.
|
||||
mimeType := detected
|
||||
if hdr := mimeFromContentType(respType, "image/"); hdr != "" {
|
||||
mimeType = hdr
|
||||
}
|
||||
return &imagegen.Result{Images: []llm.ImagePart{{MIME: mimeType, Data: raw}}}, nil
|
||||
}
|
||||
|
||||
// imageFilename picks a multipart filename for an image part. The shim reads
|
||||
// bytes, not names, but a plausible extension keeps server-side sniffing and
|
||||
// request logs honest. base distinguishes the parts of a multi-file form
|
||||
// ("target"/"source") so a log line says which one was malformed.
|
||||
//
|
||||
// initImageFilename (video.go) is this function with base fixed to "frame"
|
||||
// and delegates here — two copies of one extension table is how they drift.
|
||||
func imageFilename(mimeType, base string) string {
|
||||
if base == "" {
|
||||
base = "image"
|
||||
@@ -167,10 +184,3 @@ func imageFilename(mimeType, base string) string {
|
||||
return base + ".png"
|
||||
}
|
||||
}
|
||||
|
||||
// isImageContentType reports whether a response Content-Type is an image.
|
||||
// Used to tell a returned picture apart from a JSON error body, which would
|
||||
// otherwise be handed back as "image" bytes.
|
||||
func isImageContentType(contentType string) bool {
|
||||
return mimeFromContentType(contentType, "image/") != ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package llamaswap
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
@@ -15,15 +16,6 @@ import (
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
|
gitea-actions
commented
🟡 swapImg duplicates editInit verbatim in the same package maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **swapImg duplicates editInit verbatim in the same package**
_maintainability · flagged by 1 model_
- **`provider/llamaswap/faceswap_test.go:18-24`** — `swapImg` is a byte-for-byte duplicate of `editInit` in `edit_test.go:16-23` (same package `llamaswap`): decode `onePixelPNG`, wrap in `imagegen.Image{MIME: "image/png", Data: raw}`. Reuse `editInit` (or rename it to a capability-neutral name and use it from both files) instead of adding another near-identical helper.
<sub>🪰 Gadfly · advisory</sub>
|
||||
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()
|
||||
@@ -73,7 +65,7 @@ func TestFaceSwapSendsBothFiles(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("model: %v", err)
|
||||
}
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
res, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img},
|
||||
imagegen.WithFaceIndex(2))
|
||||
if err != nil {
|
||||
@@ -111,7 +103,7 @@ func TestFaceSwapAllSuppressesIndex(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(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)
|
||||
@@ -129,7 +121,7 @@ func TestFaceSwapAllSuppressesIndex(t *testing.T) {
|
||||
func TestFaceSwapRejectsMissingImages(t *testing.T) {
|
||||
p := New(WithBaseURL("http://example.invalid"))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req imagegen.FaceSwapRequest
|
||||
@@ -157,16 +149,18 @@ func TestFaceSwapRejectsNonImageResponse(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(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 !errors.As(err, &apiErr) {
|
||||
t.Errorf("err = %T, want *llm.APIError so callers can classify it", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no_face_in_source") {
|
||||
t.Errorf("err = %v, want it to relay the shim's reason", err)
|
||||
}
|
||||
_ = apiErr
|
||||
}
|
||||
|
||||
|
gitea-actions
commented
🟡 Dead apiErr variable (declared then maintainability · flagged by 2 models
🪰 Gadfly · advisory 🟡 **Dead apiErr variable (declared then `_ = apiErr`) — abandoned errors.As check, remove or wire up**
_maintainability · flagged by 2 models_
- **`provider/llamaswap/faceswap_test.go:165 — dead `apiErr` in `TestFaceSwapRejectsNonImageResponse`.** `var apiErr *llm.APIError` is declared and then discarded with `_ = apiErr`; nothing ever assigns to it (it looks like an abandoned `errors.As(err, &apiErr)` type assertion). The test only checks `strings.Contains`. This is confusing leftover code — either drop the two lines or turn it into a real `errors.As` assertion so the test actually pins that an `*llm.APIError` is returned. Small.
<sub>🪰 Gadfly · advisory</sub>
|
||||
// TestListFacesParsesOrdering: the shim's left-to-right index is the contract
|
||||
@@ -185,7 +179,7 @@ func TestListFacesParsesOrdering(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
faces, err := m.ListFaces(context.Background(), swapImg(t))
|
||||
faces, err := m.ListFaces(context.Background(), editInit(t))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
@@ -208,7 +202,60 @@ func TestListFacesRejectsShortBox(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
if _, err := m.ListFaces(context.Background(), swapImg(t)); err == nil {
|
||||
if _, err := m.ListFaces(context.Background(), editInit(t)); err == nil {
|
||||
t.Fatal("a 2-element box was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaceSwapRejectsHeaderlessNonImage is the regression for gadfly's
|
||||
// blocking finding on #23, agreed by both models. sniffImageMIME falls back
|
||||
// to image/png when detection is inconclusive, and the original guard only
|
||||
// looked at Content-Type — so a JSON error body sent WITHOUT a Content-Type
|
||||
// header was labelled a PNG and returned as a successful image. The shim
|
||||
// answers JSON on a semantic miss, which is precisely the body that would
|
||||
// have sailed through.
|
||||
func TestFaceSwapRejectsHeaderlessNonImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Explicitly no Content-Type — Go only sets one if we write before
|
||||
// deleting it, so clear it to model a bare upstream response.
|
||||
w.Header()["Content-Type"] = nil
|
||||
_, _ = w.Write([]byte(`{"detail":{"error":"no_face_in_target"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := editInit(t)
|
||||
_, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img})
|
||||
if err == nil {
|
||||
t.Fatal("a headerless JSON body was accepted and would have been returned as image/png")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no_face_in_target") {
|
||||
t.Errorf("err = %v, want it to relay what actually came back", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaceSwapAllowsNegativeIndexUnderAll: index is documented as ignored
|
||||
// when all=true, so validating it there would reject a well-formed request.
|
||||
func TestFaceSwapAllowsNegativeIndexUnderAll(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
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 := editInit(t)
|
||||
neg := -1
|
||||
if _, err := m.FaceSwap(context.Background(),
|
||||
imagegen.FaceSwapRequest{Target: img, Source: img, Index: &neg, All: true}); err != nil {
|
||||
t.Fatalf("negative index rejected under all=true, where it is ignored: %v", err)
|
||||
}
|
||||
// ...but still rejected when it WOULD be sent.
|
||||
if _, err := m.FaceSwap(context.Background(),
|
||||
imagegen.FaceSwapRequest{Target: img, Source: img, Index: &neg}); err == nil {
|
||||
t.Error("negative index accepted when it would actually be sent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -139,18 +138,7 @@ func singleVideoResult(provider, model, verb string, raw []byte, contentType str
|
||||
// frame from its MIME subtype. The name is provider-chosen (never
|
||||
// caller-supplied), so no sanitization is needed.
|
||||
func initImageFilename(mimeType string) string {
|
||||
mt := strings.ToLower(strings.TrimSpace(mimeType))
|
||||
if parsed, _, err := mime.ParseMediaType(mt); err == nil {
|
||||
mt = parsed
|
||||
}
|
||||
switch mt {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "frame.jpg"
|
||||
case "image/webp":
|
||||
return "frame.webp"
|
||||
default: // unknown MIME — PNG is the safe hint
|
||||
return "frame.png"
|
||||
}
|
||||
return imageFilename(mimeType, "frame")
|
||||
}
|
||||
|
||||
// formatInt renders an optional int pointer for a form field; nil = "" (omit).
|
||||
|
||||
⚪ DetectedFace.Width/Height are redundant with Box, creating two sources of truth
maintainability · flagged by 1 model
initImageFilenameatvideo.go:141-154is the same MIME→filename switch in the same package (jpeg/webp/png), andimageFilenameis a strict generalization. Real duplication. - Finding 2 confirmed:faceswap_test.go:165-169declaresvar apiErr *llm.APIError, never assigns it, and discards it with_ = apiErr. Dead/abandoned code. - Finding 3 confirmed:DetectedFace.Width/Heightatimagegen/faceswap.go:58-60duplicate whatBoxalready encodes. Low-c…🪰 Gadfly · advisory