FLUX.1 Kontext and Qwen-Image-Edit are a different kind of edit from img2img
and reach sd-server by a different path, and nothing in imagegen could
express it: EditRequest only had Init, which is noised and denoised back
under the prompt.
Measured against FLUX.1-Kontext on the netherstorm host 2026-07-30, on a
synthetic scene with a red rectangle, a blue rectangle and a flat background,
prompted "change the blue rectangle on the right to bright green, keep
everything else exactly the same":
via init_images (the only path that existed)
right rect (60,60,200) -> (47,82,228) still blue, instruction ignored
left rect (200,60,60) -> (229,43,50) drifted
background (150,200,240) -> (154,211,229) drifted
via extra_images (this change)
right rect (60,60,200) -> (70,254,4) green, as asked
left rect (200,60,60) -> (204,57,57) intact
background (150,200,240) -> (151,202,247) intact
No mask, no strength, no compositing — the model is handed the picture as
conditioning and the prompt as an instruction about it.
EditRequest.RefImages selects the path; when set, Init/Mask/Strength are
ignored rather than rejected, so a caller handing the same request to
whichever model is configured gets the better result on a Kontext-class model
instead of an error. The provider posts /sdapi/v1/txt2img with extra_images
(sd-server reads that field on both routes into gen_params.ref_images, where
the CLI's -r/--ref-image also lands); there is no init latent to denoise, so
sending one would only add noise to a pipeline that does not want any.
An all-empty reference set is refused: it would otherwise degrade into a
plain txt2img and render the prompt from scratch, which is not the request.
228 lines
8.0 KiB
Go
228 lines
8.0 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
func editInit(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}
|
|
}
|
|
|
|
func TestImageEdit(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/sdapi/v1/img2img" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("sd")
|
|
ed, ok := im.(imagegen.Editor)
|
|
if !ok {
|
|
t.Fatal("imageModel does not implement imagegen.Editor")
|
|
}
|
|
|
|
res, err := ed.Edit(context.Background(),
|
|
imagegen.EditRequest{Prompt: "make it night", Init: editInit(t)},
|
|
imagegen.WithEditStrength(0.6),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
if len(res.Images) != 1 || res.Images[0].MIME != "image/png" {
|
|
t.Fatalf("images = %+v", res.Images)
|
|
}
|
|
|
|
if gotBody["model"] != "sd" || gotBody["prompt"] != "make it night" {
|
|
t.Errorf("model/prompt = %v/%v", gotBody["model"], gotBody["prompt"])
|
|
}
|
|
inits, ok := gotBody["init_images"].([]any)
|
|
if !ok || len(inits) != 1 || inits[0] != onePixelPNG {
|
|
t.Errorf("init_images = %v, want the b64 fixture", gotBody["init_images"])
|
|
}
|
|
if gotBody["denoising_strength"] != 0.6 {
|
|
t.Errorf("denoising_strength = %v, want 0.6", gotBody["denoising_strength"])
|
|
}
|
|
}
|
|
|
|
func TestImageEditOmitsUnsetOverrides(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("sd")
|
|
ed := im.(imagegen.Editor)
|
|
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "x", Init: editInit(t)}); err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
for _, k := range []string{"denoising_strength", "steps", "cfg_scale", "negative_prompt", "sample_method", "seed", "width", "height"} {
|
|
if v, ok := gotBody[k]; ok {
|
|
t.Errorf("unset request sent %q = %v, want omitted", k, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImageEditValidation(t *testing.T) {
|
|
p := New(WithBaseURL("http://example.invalid"))
|
|
im, _ := p.ImageModel("sd")
|
|
ed := im.(imagegen.Editor)
|
|
|
|
cases := []struct {
|
|
name string
|
|
req imagegen.EditRequest
|
|
}{
|
|
{"empty prompt", imagegen.EditRequest{Prompt: " ", Init: imagegen.Image{Data: []byte{1}}}},
|
|
{"missing init", imagegen.EditRequest{Prompt: "x"}},
|
|
{"negative N", imagegen.EditRequest{Prompt: "x", Init: imagegen.Image{Data: []byte{1}}, N: -1}},
|
|
}
|
|
for _, tc := range cases {
|
|
if _, err := ed.Edit(context.Background(), tc.req); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("%s: err = %v, want ErrUnsupported", tc.name, err)
|
|
}
|
|
}
|
|
|
|
bad := 1.5
|
|
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "x", Init: imagegen.Image{Data: []byte{1}}, Strength: &bad}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("out-of-range strength: err = %v, want ErrUnsupported", err)
|
|
}
|
|
}
|
|
|
|
func TestImageEditWithMask(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("sd")
|
|
ed := im.(imagegen.Editor)
|
|
|
|
mask := imagegen.Image{MIME: "image/png", Data: []byte{0xDE, 0xAD}}
|
|
if _, err := ed.Edit(context.Background(),
|
|
imagegen.EditRequest{Prompt: "replace the sky", Init: editInit(t)},
|
|
imagegen.WithEditMask(mask),
|
|
); err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
if got := gotBody["mask"]; got != base64.StdEncoding.EncodeToString(mask.Data) {
|
|
t.Errorf("mask = %v, want the b64 mask", got)
|
|
}
|
|
}
|
|
|
|
func TestImageEditWithoutMaskOmitsField(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("sd")
|
|
ed := im.(imagegen.Editor)
|
|
if _, err := ed.Edit(context.Background(),
|
|
imagegen.EditRequest{Prompt: "p", Init: editInit(t)}); err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
if _, ok := gotBody["mask"]; ok {
|
|
t.Error("mask field sent for unmasked edit; want omitted")
|
|
}
|
|
}
|
|
|
|
// TestImageEditByReferenceUsesTxt2ImgExtraImages pins the instruction-edit
|
|
// wire shape. It is a DIFFERENT endpoint and a DIFFERENT field from img2img,
|
|
// and the difference is not cosmetic: measured against FLUX.1-Kontext on
|
|
// 2026-07-30, the same prompt sent as init_images left the thing it was told
|
|
// to change untouched and drifted everything else, while extra_images changed
|
|
// exactly what was asked and left the rest of the frame numerically
|
|
// unchanged. Routing a reference edit down the img2img path would look like
|
|
// a working call and silently produce the wrong picture.
|
|
func TestImageEditByReferenceUsesTxt2ImgExtraImages(t *testing.T) {
|
|
var gotPath string
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("imagegen-flux-kontext")
|
|
ed := im.(imagegen.Editor)
|
|
|
|
ref := editInit(t)
|
|
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{
|
|
Prompt: "make the sign read OPEN",
|
|
// Init/Mask/Strength are set and must be IGNORED — they describe a
|
|
// pipeline this model does not run.
|
|
Init: ref,
|
|
Mask: ref,
|
|
Strength: func() *float64 { s := 0.75; return &s }(),
|
|
}, imagegen.WithEditRefImages(ref)); err != nil {
|
|
t.Fatalf("reference edit: %v", err)
|
|
}
|
|
|
|
if gotPath != "/sdapi/v1/txt2img" {
|
|
t.Errorf("path = %q, want /sdapi/v1/txt2img (there is no init latent to denoise)", gotPath)
|
|
}
|
|
extra, ok := gotBody["extra_images"].([]any)
|
|
if !ok || len(extra) != 1 {
|
|
t.Fatalf("extra_images = %v, want the one reference image", gotBody["extra_images"])
|
|
}
|
|
if _, present := gotBody["init_images"]; present {
|
|
t.Error("init_images must NOT be sent on the reference path — it re-noises the picture")
|
|
}
|
|
if _, present := gotBody["denoising_strength"]; present {
|
|
t.Error("denoising_strength must NOT be sent on the reference path")
|
|
}
|
|
if _, present := gotBody["mask"]; present {
|
|
t.Error("mask must NOT be sent on the reference path")
|
|
}
|
|
}
|
|
|
|
// TestImageEditByReferenceRejectsEmptyRefs guards the case that would
|
|
// otherwise silently become a plain txt2img: a reference edit whose only
|
|
// reference carries no bytes has nothing to condition on, and rendering the
|
|
// prompt from scratch is not what the caller asked for.
|
|
func TestImageEditByReferenceRejectsEmptyRefs(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
im, _ := p.ImageModel("imagegen-flux-kontext")
|
|
ed := im.(imagegen.Editor)
|
|
|
|
_, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "anything"},
|
|
imagegen.WithEditRefImages(imagegen.Image{MIME: "image/png"}))
|
|
if !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Fatalf("err = %v, want ErrUnsupported for an all-empty reference set", err)
|
|
}
|
|
}
|