Gadfly on #23, blocking, 2/2 agreement — and it is the exact defect this whole line of work has been about: a call that succeeds while handing back the wrong bytes. sniffImageMIME falls back to image/png when detection is inconclusive, and the guard only consulted Content-Type. A response with NO Content-Type therefore skipped the check entirely and was labelled a PNG. The shim answers JSON on a semantic miss (no face found in the source or target), which is precisely the body that would have sailed through as a successful image. The check now validates the BYTES — http.DetectContentType must say image/ — and the reported MIME prefers the server's own label only when that label is itself an image type. Break-checked by restoring the header-only condition, which fails the new test. Also from that review: - index is documented as ignored under all=true, so a negative one is no longer rejected there; it is still rejected when it would actually be sent, and both halves are tested. - initImageFilename (video.go) was imageFilename with the base fixed to "frame" and now delegates to it — two copies of one extension table is how they drift. - DetectedFace carried Width/Height alongside Box, two sources of truth for one fact that can disagree after any transform. Now a Size() method derived from Box. - a dead `apiErr` in the test (declared, then `_ = apiErr`) was an abandoned errors.As check; it is wired up and now asserts callers can classify the error. - swapImg duplicated editInit verbatim; removed. Not taken: adding a FaceSwapProvider/ModelOption surface to match the other optional imagegen capabilities (single-model finding). There are no options to carry yet, and inventing an empty option type to look symmetrical would be API surface with nothing behind it. Worth revisiting when a real knob exists.
176 lines
6.3 KiB
Go
176 lines
6.3 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
|
|
)
|
|
|
|
// VideoModel implements videogen.Provider, binding a video-generation model
|
|
// served by llama-swap (routed to a vLLM-Omni-style upstream, or any shim
|
|
// exposing the same /v1/videos/sync shape). The id is passed through verbatim
|
|
// and selects which upstream llama-swap loads.
|
|
func (p *Provider) VideoModel(id string, opts ...videogen.ModelOption) (videogen.Model, error) {
|
|
if err := p.requireBaseURL(); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = videogen.ApplyModelOptions(opts)
|
|
return &videoModel{p: p, id: id}, nil
|
|
}
|
|
|
|
type videoModel struct {
|
|
p *Provider
|
|
id string
|
|
}
|
|
|
|
// Generate implements videogen.Model via POST {base}/v1/videos/sync
|
|
// (multipart/form-data — llama-swap routes by the `model` form field). The
|
|
// blocking sync endpoint answers with the encoded video itself, so the
|
|
// response body is the result; there is no job id to poll. Generation runs
|
|
// for minutes — the provider client carries no timeout by design, and callers
|
|
// bound the call with a context deadline.
|
|
//
|
|
// Parameter names follow vLLM-Omni's videos API (num_frames, fps,
|
|
// num_inference_steps, guidance_scale); the conditioning frame is sent as an
|
|
// `input_reference` file part, following OpenAI's videos API. Upstreams
|
|
// ignore fields they don't understand, and optional fields stay off the wire
|
|
// entirely so the model's own defaults apply.
|
|
func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts ...videogen.Option) (*videogen.Result, error) {
|
|
req = req.Apply(opts...)
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
return nil, fmt.Errorf("%w: video generation requires a prompt", llm.ErrUnsupported)
|
|
}
|
|
if req.NumFrames < 0 {
|
|
return nil, fmt.Errorf("%w: video frame count must be >= 0, got %d", llm.ErrUnsupported, req.NumFrames)
|
|
}
|
|
if req.FPS < 0 {
|
|
return nil, fmt.Errorf("%w: video fps must be >= 0, got %d", llm.ErrUnsupported, req.FPS)
|
|
}
|
|
if req.InitImage != nil && len(req.InitImage.Data) == 0 {
|
|
return nil, fmt.Errorf("%w: video init image has no bytes", llm.ErrUnsupported)
|
|
}
|
|
width, height, err := parseSize(req.Size)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", llm.ErrUnsupported, err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
if err := writeFormFields(w, "build video form", []formField{
|
|
{"model", m.id, true},
|
|
{"prompt", req.Prompt, true},
|
|
{"negative_prompt", req.NegativePrompt, false},
|
|
// Resolution rides the wire twice: width/height (vLLM-Omni's
|
|
// names) AND the equivalent OpenAI-style size string, since
|
|
// upstreams silently ignore fields they don't understand and the
|
|
// values can never disagree.
|
|
{"width", formatInt(width), false},
|
|
{"height", formatInt(height), false},
|
|
{"size", strings.TrimSpace(req.Size), false},
|
|
{"num_frames", formatNonZero(req.NumFrames), false},
|
|
{"fps", formatNonZero(req.FPS), false},
|
|
{"num_inference_steps", formatInt(req.Steps), false},
|
|
{"guidance_scale", formatFloat(req.GuidanceScale), false},
|
|
{"seed", formatInt64(req.Seed), false},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.InitImage != nil {
|
|
fw, err := w.CreateFormFile("input_reference", initImageFilename(req.InitImage.MIME))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("llama-swap: build video form: %w", err)
|
|
}
|
|
if _, err := fw.Write(req.InitImage.Data); err != nil {
|
|
return nil, fmt.Errorf("llama-swap: build video form: %w", err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, fmt.Errorf("llama-swap: build video form: %w", err)
|
|
}
|
|
|
|
videoBytes, contentType, err := m.p.doRaw(ctx, http.MethodPost, "/v1/videos/sync", m.id, w.FormDataContentType(), &buf, maxVideoResponseBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return singleVideoResult(m.p.name, m.id, "video", videoBytes, contentType)
|
|
}
|
|
|
|
// videoMIME resolves the result MIME type: the response Content-Type when it
|
|
// is a concrete video type, else content sniffing (mp4/webm magic bytes),
|
|
// else "" — the caller treats undetectable as an upstream error, unlike the
|
|
// audio path where the request's format param implies the container.
|
|
func videoMIME(contentType string, data []byte) string {
|
|
if mt := mimeFromContentType(contentType, "video/"); mt != "" {
|
|
return mt
|
|
}
|
|
if mt := http.DetectContentType(data); strings.HasPrefix(mt, "video/") {
|
|
return mt
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// singleVideoResult wraps one raw video body into a videogen.Result,
|
|
// requiring positive evidence of video-ness (declared video/* Content-Type
|
|
// or sniffed mp4/webm magic) so a 2xx body that is anything else — a JSON
|
|
// job envelope, an HTML error page behind a proxy — fails loud instead of
|
|
// coming back as "the clip". The video sibling of singleImageResult, shared
|
|
// by every surface whose response body IS the encoded clip.
|
|
func singleVideoResult(provider, model, verb string, raw []byte, contentType string) (*videogen.Result, error) {
|
|
if len(raw) == 0 {
|
|
return nil, &llm.APIError{Provider: provider, Model: model, Message: verb + " response contained no video"}
|
|
}
|
|
mimeType := videoMIME(contentType, raw)
|
|
if mimeType == "" {
|
|
return nil, &llm.APIError{Provider: provider, Model: model,
|
|
Message: fmt.Sprintf("%s response is not a video (Content-Type %q)", verb, contentType)}
|
|
}
|
|
return &videogen.Result{Video: videogen.Video{Data: raw, MIME: mimeType}}, nil
|
|
}
|
|
|
|
// initImageFilename picks the multipart filename hint for the conditioning
|
|
// frame from its MIME subtype. The name is provider-chosen (never
|
|
// caller-supplied), so no sanitization is needed.
|
|
func initImageFilename(mimeType string) string {
|
|
return imageFilename(mimeType, "frame")
|
|
}
|
|
|
|
// formatInt renders an optional int pointer for a form field; nil = "" (omit).
|
|
func formatInt(v *int) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(*v)
|
|
}
|
|
|
|
// formatInt64 renders an optional int64 pointer for a form field; nil = "" (omit).
|
|
func formatInt64(v *int64) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(*v, 10)
|
|
}
|
|
|
|
// formatFloat renders an optional float pointer for a form field; nil = "" (omit).
|
|
func formatFloat(v *float64) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return strconv.FormatFloat(*v, 'g', -1, 64)
|
|
}
|
|
|
|
// formatNonZero renders a non-negative int for a form field; 0 = "" (omit,
|
|
// backend default).
|
|
func formatNonZero(v int) string {
|
|
if v == 0 {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(v)
|
|
}
|