Caught while writing the receiving end. Distinct multipart field names are not sufficient: backends stage an uploaded frame under a name derived from the FILENAME, and our own ComfyUI shim posts to /upload/image with overwrite=true. Both parts were sending initImageFilename(mime) — literally "frame.png" for each — so the second upload would have clobbered the first and BOTH keyframe inputs would have resolved to the same stored image. The failure mode is the worst kind: a clip pinned at both ends to the same frame renders cleanly, returns 200, and looks like the feature not working rather than like a bug. Nothing upstream or downstream would report a fault. writeImagePart now takes the filename stem (frame / frame_last), and the test asserts the two arrive under different filenames. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01PLjgrxvHjm1sJgUu9zBPH9
357 lines
12 KiB
Go
357 lines
12 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
|
|
)
|
|
|
|
func TestVideoGenerate(t *testing.T) {
|
|
var gotPath, gotContentType string
|
|
var gotForm map[string]string
|
|
var gotFrame []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
t.Errorf("parse form: %v", err)
|
|
return
|
|
}
|
|
gotForm = map[string]string{}
|
|
for k, v := range r.MultipartForm.Value {
|
|
gotForm[k] = v[0]
|
|
}
|
|
if f, _, err := r.FormFile("input_reference"); err == nil {
|
|
gotFrame, _ = io.ReadAll(f)
|
|
f.Close()
|
|
}
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write([]byte("fake-mp4-bytes"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, err := p.VideoModel("videogen-wan")
|
|
if err != nil {
|
|
t.Fatalf("VideoModel: %v", err)
|
|
}
|
|
|
|
frame, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
res, err := vm.Generate(context.Background(),
|
|
videogen.Request{Prompt: "a cat surfing", InitImage: &videogen.Image{MIME: "image/png", Data: frame}},
|
|
videogen.WithSize("1280x704"),
|
|
videogen.WithNumFrames(81),
|
|
videogen.WithFPS(16),
|
|
videogen.WithSteps(4),
|
|
videogen.WithGuidanceScale(1.0),
|
|
videogen.WithNegativePrompt("blurry"),
|
|
videogen.WithSeed(42),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if string(res.Video.Data) != "fake-mp4-bytes" || res.Video.MIME != "video/mp4" {
|
|
t.Fatalf("video = %d bytes, MIME %q", len(res.Video.Data), res.Video.MIME)
|
|
}
|
|
|
|
if gotPath != "/v1/videos/sync" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if !strings.HasPrefix(gotContentType, "multipart/form-data") {
|
|
t.Errorf("content-type = %q", gotContentType)
|
|
}
|
|
want := map[string]string{
|
|
"model": "videogen-wan",
|
|
"prompt": "a cat surfing",
|
|
"negative_prompt": "blurry",
|
|
"width": "1280",
|
|
"height": "704",
|
|
"num_frames": "81",
|
|
"fps": "16",
|
|
"num_inference_steps": "4",
|
|
"guidance_scale": "1",
|
|
"seed": "42",
|
|
}
|
|
for k, v := range want {
|
|
if gotForm[k] != v {
|
|
t.Errorf("form[%q] = %q, want %q", k, gotForm[k], v)
|
|
}
|
|
}
|
|
if string(gotFrame) != string(frame) {
|
|
t.Errorf("input_reference = %d bytes, want %d", len(gotFrame), len(frame))
|
|
}
|
|
}
|
|
|
|
func TestVideoGenerateOmitsUnsetOverrides(t *testing.T) {
|
|
var gotForm map[string][]string
|
|
var hadFrame bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseMultipartForm(32 << 20)
|
|
gotForm = r.MultipartForm.Value
|
|
_, _, err := r.FormFile("input_reference")
|
|
hadFrame = err == nil
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write([]byte("v"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, _ := p.VideoModel("videogen-wan")
|
|
if _, err := vm.Generate(context.Background(), videogen.Request{Prompt: "x"}); err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
for _, k := range []string{"negative_prompt", "width", "height", "num_frames", "fps", "num_inference_steps", "guidance_scale", "seed"} {
|
|
if _, ok := gotForm[k]; ok {
|
|
t.Errorf("form field %q sent, want omitted", k)
|
|
}
|
|
}
|
|
if hadFrame {
|
|
t.Error("input_reference sent, want omitted")
|
|
}
|
|
}
|
|
|
|
func TestVideoGenerateValidation(t *testing.T) {
|
|
p := New(WithBaseURL("http://unused.invalid"))
|
|
vm, _ := p.VideoModel("videogen-wan")
|
|
|
|
cases := []struct {
|
|
name string
|
|
req videogen.Request
|
|
}{
|
|
{"empty prompt", videogen.Request{}},
|
|
{"negative frames", videogen.Request{Prompt: "x", NumFrames: -1}},
|
|
{"negative fps", videogen.Request{Prompt: "x", FPS: -1}},
|
|
{"empty init image", videogen.Request{Prompt: "x", InitImage: &videogen.Image{}}},
|
|
{"bad size", videogen.Request{Prompt: "x", Size: "banana"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := vm.Generate(context.Background(), tc.req)
|
|
if !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Fatalf("err = %v, want ErrUnsupported", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideoGenerateUpstreamError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `{"error":{"message":"boom"}}`, http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, _ := p.VideoModel("videogen-wan")
|
|
_, err := vm.Generate(context.Background(), videogen.Request{Prompt: "x"})
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want *llm.APIError", err)
|
|
}
|
|
}
|
|
|
|
func TestVideoGenerateEmptyResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, _ := p.VideoModel("videogen-wan")
|
|
_, err := vm.Generate(context.Background(), videogen.Request{Prompt: "x"})
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want *llm.APIError for empty body", err)
|
|
}
|
|
}
|
|
|
|
func TestVideoModelRequiresBaseURL(t *testing.T) {
|
|
p := New()
|
|
if _, err := p.VideoModel("videogen-wan"); err == nil {
|
|
t.Fatal("VideoModel with no base URL should error")
|
|
}
|
|
}
|
|
|
|
func TestVideoMIME(t *testing.T) {
|
|
// A minimal ISO-BMFF prefix http.DetectContentType sniffs as video/mp4
|
|
// (the "mp4" brand prefix must appear inside the declared ftyp box).
|
|
mp4Magic := append([]byte{0, 0, 0, 20}, []byte("ftypmp42\x00\x00\x00\x00mp42")...)
|
|
cases := []struct {
|
|
contentType string
|
|
data []byte
|
|
want string
|
|
}{
|
|
{"video/webm", []byte("x"), "video/webm"},
|
|
{"video/mp4; charset=binary", []byte("x"), "video/mp4"},
|
|
{"application/octet-stream", mp4Magic, "video/mp4"},
|
|
// Neither declared nor sniffable as video → "" (Generate errors).
|
|
{"application/octet-stream", []byte(`{"id":"job-1"}`), ""},
|
|
{"", []byte("x"), ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := videoMIME(tc.contentType, tc.data); got != tc.want {
|
|
t.Errorf("videoMIME(%q, %q) = %q, want %q", tc.contentType, tc.data, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVideoGenerateNonVideoBodyErrors(t *testing.T) {
|
|
// A stock async /v1/videos handler mounted at the sync path (or an HTML
|
|
// error page behind a proxy) answers 200 with a non-video body — that
|
|
// must be an error, never a "successful" garbage clip.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"job-1","status":"queued"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, _ := p.VideoModel("videogen-wan")
|
|
_, err := vm.Generate(context.Background(), videogen.Request{Prompt: "x"})
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want *llm.APIError for non-video 2xx body", err)
|
|
}
|
|
if !strings.Contains(apiErr.Message, "not a video") {
|
|
t.Errorf("message = %q, want mention of non-video body", apiErr.Message)
|
|
}
|
|
}
|
|
|
|
// Both keyframes reach the wire, under DISTINCT field names.
|
|
//
|
|
// The distinct-name property is the actual contract with the backend shim: the
|
|
// two frames could have shared one repeated `input_reference` name, and then
|
|
// which is first and which is last would depend on multipart part ORDER — an
|
|
// ordering contract invisible in the payload, that nothing would notice
|
|
// breaking. Asserting the names is what pins it.
|
|
func TestVideoGenerateSendsBothKeyframes(t *testing.T) {
|
|
var gotFirst, gotLast []byte
|
|
var firstName, lastName string
|
|
var sawLastPart bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
t.Errorf("parse form: %v", err)
|
|
return
|
|
}
|
|
if f, hdr, err := r.FormFile("input_reference"); err == nil {
|
|
gotFirst, _ = io.ReadAll(f)
|
|
firstName = hdr.Filename
|
|
f.Close()
|
|
}
|
|
if f, hdr, err := r.FormFile("input_reference_last"); err == nil {
|
|
sawLastPart = true
|
|
gotLast, _ = io.ReadAll(f)
|
|
lastName = hdr.Filename
|
|
f.Close()
|
|
}
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write([]byte("fake-mp4-bytes"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, err := p.VideoModel("videogen-minimax-h3")
|
|
if err != nil {
|
|
t.Fatalf("VideoModel: %v", err)
|
|
}
|
|
|
|
first, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
last := append(append([]byte{}, first...), 0x00) // distinguishable from first
|
|
|
|
if _, err := vm.Generate(context.Background(), videogen.Request{
|
|
Prompt: "a cat surfing",
|
|
InitImage: &videogen.Image{MIME: "image/png", Data: first},
|
|
LastImage: &videogen.Image{MIME: "image/png", Data: last},
|
|
}); err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
|
|
if !sawLastPart {
|
|
t.Fatal("input_reference_last was not sent — a pinned end frame would be silently dropped")
|
|
}
|
|
if string(gotFirst) != string(first) {
|
|
t.Errorf("input_reference = %d bytes, want %d", len(gotFirst), len(first))
|
|
}
|
|
if string(gotLast) != string(last) {
|
|
t.Errorf("input_reference_last = %d bytes, want %d", len(gotLast), len(last))
|
|
}
|
|
// The two must not be the same bytes, or a swap/aliasing bug reads as a pass.
|
|
if string(gotFirst) == string(gotLast) {
|
|
t.Error("both parts carry identical bytes — the frames are being aliased")
|
|
}
|
|
// DISTINCT FILENAMES, not just distinct field names. Backends stage an
|
|
// uploaded frame under a name derived from the filename (our ComfyUI shim
|
|
// posts to /upload/image with overwrite=true), so two parts sharing
|
|
// "frame.png" would have the second clobber the first and BOTH keyframes
|
|
// would resolve to the same stored image — a clip pinned at both ends to
|
|
// the same frame, rendering cleanly with nothing reporting a fault.
|
|
if firstName == "" || lastName == "" {
|
|
t.Fatalf("filenames = %q / %q, want both set", firstName, lastName)
|
|
}
|
|
if firstName == lastName {
|
|
t.Errorf("both parts use filename %q — the second upload would clobber the first", firstName)
|
|
}
|
|
}
|
|
|
|
// LastImage alone (no InitImage) is a legitimate request: pin the destination
|
|
// and let the model invent the approach. It must not require a first frame.
|
|
func TestVideoGenerateLastImageAloneIsAllowed(t *testing.T) {
|
|
var sawFirst, sawLast bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
t.Errorf("parse form: %v", err)
|
|
return
|
|
}
|
|
if f, _, err := r.FormFile("input_reference"); err == nil {
|
|
sawFirst = true
|
|
f.Close()
|
|
}
|
|
if f, _, err := r.FormFile("input_reference_last"); err == nil {
|
|
sawLast = true
|
|
f.Close()
|
|
}
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write([]byte("fake-mp4-bytes"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
vm, _ := p.VideoModel("videogen-minimax-h3")
|
|
frame, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
|
|
|
if _, err := vm.Generate(context.Background(),
|
|
videogen.Request{Prompt: "arrive here"},
|
|
videogen.WithLastImage(videogen.Image{MIME: "image/png", Data: frame}),
|
|
); err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
if sawFirst {
|
|
t.Error("input_reference sent, want omitted")
|
|
}
|
|
if !sawLast {
|
|
t.Error("input_reference_last omitted, want sent")
|
|
}
|
|
}
|
|
|
|
// An empty LastImage is rejected before the request is built, matching
|
|
// InitImage's existing contract — a zero-byte frame reaching the backend is a
|
|
// confusing upstream error instead of a clear local one.
|
|
func TestVideoGenerateRejectsEmptyLastImage(t *testing.T) {
|
|
p := New(WithBaseURL("http://unused"))
|
|
vm, _ := p.VideoModel("videogen-minimax-h3")
|
|
_, err := vm.Generate(context.Background(), videogen.Request{
|
|
Prompt: "x",
|
|
LastImage: &videogen.Image{MIME: "image/png"},
|
|
})
|
|
if !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Fatalf("err = %v, want llm.ErrUnsupported", err)
|
|
}
|
|
}
|