Merge pull request 'feat(videogen): canonical video-generation surface + llama-swap client' (#13) from feat/videogen into main
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -34,6 +34,11 @@ majordomo Registry, Parse, env-DSN loading, chain executor, re-exports
|
|||||||
Tool/Toolbox, Capabilities, Stream, Model, Provider, errors
|
Tool/Toolbox, Capabilities, Stream, Model, Provider, errors
|
||||||
imagegen/ canonical text-to-image contract: Request/Result/Model/
|
imagegen/ canonical text-to-image contract: Request/Result/Model/
|
||||||
Provider (separate from llm; Image = llm.ImagePart) (ADR-0016)
|
Provider (separate from llm; Image = llm.ImagePart) (ADR-0016)
|
||||||
|
audio/ canonical speech contracts: SpeechModel (TTS) +
|
||||||
|
TranscriptionModel (STT), same conventions (ADR-0017)
|
||||||
|
videogen/ canonical video-generation contract: one Model for
|
||||||
|
text-to-video + image-to-video (InitImage), single-clip
|
||||||
|
Result (Image = llm.ImagePart) (ADR-0019)
|
||||||
health/ clock-injected health tracker (bench/backoff)
|
health/ clock-injected health tracker (bench/backoff)
|
||||||
media/ image normalization to target capabilities (sniff real
|
media/ image normalization to target capabilities (sniff real
|
||||||
format, downscale, transcode, byte ladder; ErrUnsupported
|
format, downscale, transcode, byte ladder; ErrUnsupported
|
||||||
@@ -44,7 +49,8 @@ majordomo Registry, Parse, env-DSN loading, chain executor, re-exports
|
|||||||
provider/ollama/ one native /api/chat client serving the ollama,
|
provider/ollama/ one native /api/chat client serving the ollama,
|
||||||
ollama-cloud, and foreman built-ins via presets
|
ollama-cloud, and foreman built-ins via presets
|
||||||
provider/llamaswap/ llama-swap proxy: chat delegates to provider/openai,
|
provider/llamaswap/ llama-swap proxy: chat delegates to provider/openai,
|
||||||
plus management methods + imagegen image client (ADR-0015)
|
plus management methods + imagegen/audio/videogen
|
||||||
|
clients (ADR-0015..0019)
|
||||||
provider/google/ Gemini on google.golang.org/genai (the one approved
|
provider/google/ Gemini on google.golang.org/genai (the one approved
|
||||||
dependency; lazy client, raw-JSON-schema tools,
|
dependency; lazy client, raw-JSON-schema tools,
|
||||||
ThinkingLevel reasoning, iter.Pull2 streaming)
|
ThinkingLevel reasoning, iter.Pull2 streaming)
|
||||||
|
|||||||
@@ -266,6 +266,24 @@ tr, err := tm.Transcribe(ctx, audio.TranscriptionRequest{
|
|||||||
voices, err := ls.ListVoices(ctx, "kokoro") // []string of voice ids
|
voices, err := ls.ListVoices(ctx, "kokoro") // []string of voice ids
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Video: text-to-video + image-to-video
|
||||||
|
|
||||||
|
Video generation lives in the `videogen` package (ADR-0019), mirroring
|
||||||
|
imagegen/audio: one small `Model` contract, zero values mean backend
|
||||||
|
defaults, bytes in/out. Text-to-video and image-to-video are one surface —
|
||||||
|
a nil `InitImage` is a pure text prompt; setting it conditions generation
|
||||||
|
on that frame (hybrid checkpoints like Wan 2.2 TI2V serve both). First
|
||||||
|
backend: llama-swap (blocking `/v1/videos/sync`, vLLM-Omni style — the
|
||||||
|
response body is the encoded clip, so `Result` carries a single `Video`).
|
||||||
|
Generation runs for minutes; bound the call with a context deadline.
|
||||||
|
|
||||||
|
```go
|
||||||
|
vm, _ := ls.VideoModel("videogen-wan22-5b")
|
||||||
|
res, err := vm.Generate(ctx, videogen.Request{Prompt: "a cat surfing"},
|
||||||
|
videogen.WithSize("1280x704"), videogen.WithNumFrames(81))
|
||||||
|
// res.Video.Data ([]byte) + res.Video.MIME ("video/mp4")
|
||||||
|
```
|
||||||
|
|
||||||
## Tool calls
|
## Tool calls
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -398,7 +416,8 @@ response as a single delta plus final event.
|
|||||||
capabilities are present at the client level; whether a given call succeeds
|
capabilities are present at the client level; whether a given call succeeds
|
||||||
depends on the llama.cpp model llama-swap loads. llama-swap also provides
|
depends on the llama.cpp model llama-swap loads. llama-swap also provides
|
||||||
**image generation + editing** (`imagegen`), **speech synthesis +
|
**image generation + editing** (`imagegen`), **speech synthesis +
|
||||||
transcription** (`audio`) — separate axes, not shown above — plus a `Health`
|
transcription** (`audio`), **video generation** (`videogen`) — separate
|
||||||
|
axes, not shown above — plus a `Health`
|
||||||
probe and management methods on `*llamaswap.Provider`.
|
probe and management methods on `*llamaswap.Provider`.
|
||||||
|
|
||||||
Notes: Ollama has no native tool_choice — `"none"` drops the tools;
|
Notes: Ollama has no native tool_choice — `"none"` drops the tools;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ADR-0019: videogen — canonical video-generation surface
|
||||||
|
|
||||||
|
**Status:** Accepted — 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
mort is adding local video generation (Wan 2.2 / LTX-class models on a 24GB
|
||||||
|
GPU) behind the same llama-swap instance that serves imagegen and audio. Like
|
||||||
|
those modalities, video generation shares none of the chat machinery, so it
|
||||||
|
needs its own small contract package (the ADR-0016/0017 pattern). Three shape
|
||||||
|
questions: one interface for text-to-video and image-to-video or two, batch or
|
||||||
|
single result, and which wire endpoint the llama-swap provider targets.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **New `videogen/` package** with the established conventions: `Request` /
|
||||||
|
`Result` / `Option` + `Apply`, `Model` / `ModelOption` / `Provider`, zero
|
||||||
|
values mean backend default, `Image = llm.ImagePart`.
|
||||||
|
- **Text-to-video and image-to-video are one surface.** `Request.InitImage
|
||||||
|
*Image` (nil = pure text-to-video) instead of an imagegen-style separate
|
||||||
|
`Editor` interface: hybrid checkpoints (Wan 2.2 TI2V) serve both modes from
|
||||||
|
the same model and endpoint, so a second interface would duplicate the
|
||||||
|
request shape for no dispatch benefit.
|
||||||
|
- **`Result` carries a single `Video`, not a batch.** The blocking sync
|
||||||
|
endpoint answers with the encoded clip as the response body — one request,
|
||||||
|
one clip. Batching multi-minute generations behind one HTTP request is the
|
||||||
|
wrong shape; if batch ever matters it arrives with an async job surface,
|
||||||
|
not by widening this one.
|
||||||
|
- **provider/llamaswap targets `POST {base}/v1/videos/sync`**
|
||||||
|
(multipart/form-data, llama-swap routes by the `model` form field; the
|
||||||
|
response body is the video). This is vLLM-Omni's blocking videos endpoint,
|
||||||
|
and the steve/llama-swap fork dispatches it as a model route. Parameter
|
||||||
|
names follow vLLM-Omni (`num_frames`, `fps`, `num_inference_steps`,
|
||||||
|
`guidance_scale`); the conditioning frame is an `input_reference` file part
|
||||||
|
per OpenAI's videos API. Optional fields stay off the wire so per-model
|
||||||
|
launch-flag defaults apply — the imagegen convention.
|
||||||
|
- **No polling in v1.** The async `POST /v1/videos` + `GET /v1/videos/{id}`
|
||||||
|
job flow is deliberately not wrapped: callers (mort's skill tools) already
|
||||||
|
run synchronous-with-generous-timeout and bound the call with a context
|
||||||
|
deadline. An async `Job` surface is a compatible later addition.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A `videogen.Video` is its own type (`Data []byte`, `MIME string`); there is
|
||||||
|
no `llm.VideoPart`, and no chat-side video-input support is implied.
|
||||||
|
- Duration is expressed as `NumFrames` + `FPS` (the diffusion-native knobs),
|
||||||
|
not seconds; callers wanting seconds convert at their edge.
|
||||||
|
- Any upstream exposing the same `/v1/videos/sync` shape (e.g. a ComfyUI
|
||||||
|
shim) works unchanged; the contract does not name an engine.
|
||||||
@@ -22,3 +22,4 @@ One decision per file, append-only; supersede rather than rewrite.
|
|||||||
| [0016](0016-imagegen-interface.md) | imagegen — a canonical text-to-image interface | Accepted |
|
| [0016](0016-imagegen-interface.md) | imagegen — a canonical text-to-image interface | Accepted |
|
||||||
| [0017](0017-audio-interfaces.md) | audio — canonical speech synthesis + transcription interfaces | Accepted |
|
| [0017](0017-audio-interfaces.md) | audio — canonical speech synthesis + transcription interfaces | Accepted |
|
||||||
| [0018](0018-imagegen-editor.md) | imagegen.Editor — image-to-image as a separate optional interface | Accepted |
|
| [0018](0018-imagegen-editor.md) | imagegen.Editor — image-to-image as a separate optional interface | Accepted |
|
||||||
|
| [0019](0019-videogen-interface.md) | videogen — canonical video-generation surface | Accepted |
|
||||||
|
|||||||
+14
-25
@@ -63,7 +63,7 @@ func (m *speechModel) Speak(ctx context.Context, req audio.SpeechRequest, opts .
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("llama-swap: encode speech request: %w", err)
|
return nil, fmt.Errorf("llama-swap: encode speech request: %w", err)
|
||||||
}
|
}
|
||||||
audioBytes, contentType, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/speech", m.id, "application/json", bytes.NewReader(body))
|
audioBytes, contentType, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/speech", m.id, "application/json", bytes.NewReader(body), maxResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func (m *speechModel) Speak(ctx context.Context, req audio.SpeechRequest, opts .
|
|||||||
// is a concrete audio type, else a mapping from the requested format, else
|
// is a concrete audio type, else a mapping from the requested format, else
|
||||||
// audio/mpeg (the OpenAI endpoint's default container is mp3).
|
// audio/mpeg (the OpenAI endpoint's default container is mp3).
|
||||||
func speechMIME(contentType, format string) string {
|
func speechMIME(contentType, format string) string {
|
||||||
if mt, _, err := mime.ParseMediaType(contentType); err == nil && strings.HasPrefix(mt, "audio/") {
|
if mt := mimeFromContentType(contentType, "audio/"); mt != "" {
|
||||||
return mt
|
return mt
|
||||||
}
|
}
|
||||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
@@ -128,30 +128,19 @@ func (m *transcriptionModel) Transcribe(ctx context.Context, req audio.Transcrip
|
|||||||
if _, err := fw.Write(req.Audio); err != nil {
|
if _, err := fw.Write(req.Audio); err != nil {
|
||||||
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
|
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
|
||||||
}
|
}
|
||||||
// Required fields always go on the wire (an empty model id should fail
|
if err := writeFormFields(w, "build transcription form", []formField{
|
||||||
// loudly upstream, not silently vanish); optional ones only when set.
|
|
||||||
fields := []struct {
|
|
||||||
key, value string
|
|
||||||
required bool
|
|
||||||
}{
|
|
||||||
{"model", m.id, true},
|
{"model", m.id, true},
|
||||||
{"response_format", "json", true},
|
{"response_format", "json", true},
|
||||||
{"language", req.Language, false},
|
{"language", req.Language, false},
|
||||||
{"prompt", req.Prompt, false},
|
{"prompt", req.Prompt, false},
|
||||||
}
|
}); err != nil {
|
||||||
for _, f := range fields {
|
return nil, err
|
||||||
if !f.required && f.value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := w.WriteField(f.key, f.value); err != nil {
|
|
||||||
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := w.Close(); err != nil {
|
if err := w.Close(); err != nil {
|
||||||
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
|
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/transcriptions", m.id, w.FormDataContentType(), &buf)
|
raw, _, err := m.p.doRaw(ctx, http.MethodPost, "/v1/audio/transcriptions", m.id, w.FormDataContentType(), &buf, maxResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -212,7 +201,7 @@ func (p *Provider) ListVoices(ctx context.Context, model string) ([]string, erro
|
|||||||
if model == "" {
|
if model == "" {
|
||||||
return nil, fmt.Errorf("llama-swap: ListVoices requires a model id")
|
return nil, fmt.Errorf("llama-swap: ListVoices requires a model id")
|
||||||
}
|
}
|
||||||
raw, _, err := p.doRaw(ctx, http.MethodGet, "/v1/audio/voices?model="+url.QueryEscape(model), model, "", nil)
|
raw, _, err := p.doRaw(ctx, http.MethodGet, "/v1/audio/voices?model="+url.QueryEscape(model), model, "", nil, maxResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -267,10 +256,10 @@ func parseVoices(raw []byte) ([]string, error) {
|
|||||||
|
|
||||||
// doRaw performs a request to a llama-swap endpoint and returns the raw
|
// doRaw performs a request to a llama-swap endpoint and returns the raw
|
||||||
// response body and its Content-Type — the sibling of doJSON for endpoints
|
// response body and its Content-Type — the sibling of doJSON for endpoints
|
||||||
// whose success payload is not JSON (audio bytes) or whose shape varies.
|
// whose success payload is not JSON (audio/video bytes) or whose shape
|
||||||
// contentType sets the request Content-Type when body is non-nil. A response
|
// varies. contentType sets the request Content-Type when body is non-nil.
|
||||||
// larger than maxResponseBytes is an error, never a silent truncation.
|
// A response larger than maxBytes is an error, never a silent truncation.
|
||||||
func (p *Provider) doRaw(ctx context.Context, method, path, model, contentType string, body io.Reader) ([]byte, string, error) {
|
func (p *Provider) doRaw(ctx context.Context, method, path, model, contentType string, body io.Reader, maxBytes int64) ([]byte, string, error) {
|
||||||
if err := p.requireBaseURL(); err != nil {
|
if err := p.requireBaseURL(); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
@@ -286,12 +275,12 @@ func (p *Provider) doRaw(ctx context.Context, method, path, model, contentType s
|
|||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
return nil, "", p.apiError(resp, model)
|
return nil, "", p.apiError(resp, model)
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("llama-swap: read response: %w", err)
|
return nil, "", fmt.Errorf("llama-swap: read response: %w", err)
|
||||||
}
|
}
|
||||||
if len(data) > maxResponseBytes {
|
if int64(len(data)) > maxBytes {
|
||||||
return nil, "", fmt.Errorf("llama-swap: response exceeds %d bytes", maxResponseBytes)
|
return nil, "", fmt.Errorf("llama-swap: response exceeds %d bytes", maxBytes)
|
||||||
}
|
}
|
||||||
return data, resp.Header.Get("Content-Type"), nil
|
return data, resp.Header.Get("Content-Type"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,6 +47,12 @@ const DefaultName = "llama-swap"
|
|||||||
// can't make a decode allocate without limit.
|
// can't make a decode allocate without limit.
|
||||||
const maxResponseBytes = 64 << 20
|
const maxResponseBytes = 64 << 20
|
||||||
|
|
||||||
|
// maxVideoResponseBytes caps the /v1/videos/sync body — the response IS an
|
||||||
|
// encoded clip, which legitimately dwarfs any JSON/audio payload (a long
|
||||||
|
// high-bitrate generation can pass 64MB). Still bounded so a buggy upstream
|
||||||
|
// can't allocate without limit.
|
||||||
|
const maxVideoResponseBytes = 512 << 20
|
||||||
|
|
||||||
// Provider is a llama-swap client. It satisfies llm.Provider (chat, delegated
|
// Provider is a llama-swap client. It satisfies llm.Provider (chat, delegated
|
||||||
// to provider/openai) and imagegen.Provider (image generation), and exposes
|
// to provider/openai) and imagegen.Provider (image generation), and exposes
|
||||||
// llama-swap's management endpoints as concrete methods.
|
// llama-swap's management endpoints as concrete methods.
|
||||||
@@ -280,3 +288,34 @@ func (p *Provider) apiError(resp *http.Response, model string) error {
|
|||||||
e.Message = strings.TrimSpace(string(body))
|
e.Message = strings.TrimSpace(string(body))
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formField is one multipart form entry for the media endpoints. Required
|
||||||
|
// fields always go on the wire (an empty model id should fail loudly
|
||||||
|
// upstream, not silently vanish); optional ones only when set.
|
||||||
|
type formField struct {
|
||||||
|
key, value string
|
||||||
|
required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFormFields appends fields to a multipart writer, skipping unset
|
||||||
|
// optional entries. wrap labels errors ("build transcription form", ...).
|
||||||
|
func writeFormFields(w *multipart.Writer, wrap string, fields []formField) error {
|
||||||
|
for _, f := range fields {
|
||||||
|
if !f.required && f.value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := w.WriteField(f.key, f.value); err != nil {
|
||||||
|
return fmt.Errorf("llama-swap: %s: %w", wrap, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeFromContentType returns the parsed media type when it matches prefix
|
||||||
|
// ("audio/", "video/"), else "".
|
||||||
|
func mimeFromContentType(contentType, prefix string) string {
|
||||||
|
if mt, _, err := mime.ParseMediaType(contentType); err == nil && strings.HasPrefix(mt, prefix) {
|
||||||
|
return mt
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package llamaswap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
if len(videoBytes) == 0 {
|
||||||
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "video response contained no video"}
|
||||||
|
}
|
||||||
|
mimeType := videoMIME(contentType, videoBytes)
|
||||||
|
if mimeType == "" {
|
||||||
|
// A 2xx body that is neither declared nor sniffable as video is a
|
||||||
|
// misconfigured upstream (a JSON job envelope, an HTML error page
|
||||||
|
// behind a proxy) — fail loud rather than hand back garbage as a
|
||||||
|
// playable clip.
|
||||||
|
return nil, &llm.APIError{
|
||||||
|
Provider: m.p.name,
|
||||||
|
Model: m.id,
|
||||||
|
Message: fmt.Sprintf("video response is not a video (content-type %q)", contentType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &videogen.Result{Video: videogen.Video{Data: videoBytes, MIME: mimeType}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Package videogen is majordomo's canonical video-generation surface. Like
|
||||||
|
// imagegen and audio, it is a deliberately separate contract from the llm
|
||||||
|
// package: video generation shares none of the chat message/tool/stream
|
||||||
|
// machinery, so it gets its own small Provider/Model interface rather than
|
||||||
|
// overloading llm.Model (ADR-0019).
|
||||||
|
//
|
||||||
|
// Zero values mean "backend default" throughout, mirroring imagegen: an empty
|
||||||
|
// Size leaves the backend's default resolution, zero NumFrames/FPS the
|
||||||
|
// backend's default clip length and rate.
|
||||||
|
//
|
||||||
|
// Text-to-video and image-to-video are one surface: a Request with a nil
|
||||||
|
// InitImage is a pure text prompt, a non-nil InitImage conditions generation
|
||||||
|
// on that frame. Hybrid models (e.g. Wan 2.2 TI2V) serve both from the same
|
||||||
|
// checkpoint, so unlike imagegen there is no separate Editor-style interface.
|
||||||
|
//
|
||||||
|
// The first implementation is provider/llamaswap, which targets the blocking
|
||||||
|
// OpenAI/vLLM-Omni-style POST /v1/videos/sync endpoint: the response body is
|
||||||
|
// the encoded video itself, so one request yields exactly one clip — Result
|
||||||
|
// carries a single Video, not a batch.
|
||||||
|
package videogen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Image is a conditioning input frame (bytes + MIME). Aliased to
|
||||||
|
// llm.ImagePart so chat-sourced images feed image-to-video without
|
||||||
|
// conversion, mirroring imagegen.Image.
|
||||||
|
type Image = llm.ImagePart
|
||||||
|
|
||||||
|
// Video is one generated video: raw encoded bytes plus a MIME type
|
||||||
|
// (e.g. "video/mp4").
|
||||||
|
type Video struct {
|
||||||
|
// Data is the encoded video container.
|
||||||
|
Data []byte
|
||||||
|
|
||||||
|
// MIME is the video MIME type, e.g. "video/mp4".
|
||||||
|
MIME string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is a video generation request. Zero values mean "backend default" —
|
||||||
|
// for llama-swap-served models that is the per-model default baked into the
|
||||||
|
// upstream launch flags. A caller overrides only what it explicitly sets.
|
||||||
|
type Request struct {
|
||||||
|
// Prompt is the text description of the video to generate.
|
||||||
|
Prompt string
|
||||||
|
|
||||||
|
// InitImage conditions generation on a starting frame (image-to-video);
|
||||||
|
// nil = pure text-to-video.
|
||||||
|
InitImage *Image
|
||||||
|
|
||||||
|
// Size is the requested resolution, e.g. "1280x704"; "" = backend default.
|
||||||
|
Size string
|
||||||
|
|
||||||
|
// NumFrames is the clip length in frames; 0 = backend default.
|
||||||
|
NumFrames int
|
||||||
|
|
||||||
|
// FPS is the frame rate of the generated clip; 0 = backend default.
|
||||||
|
FPS int
|
||||||
|
|
||||||
|
// Steps is the number of diffusion steps; nil = backend default.
|
||||||
|
Steps *int
|
||||||
|
|
||||||
|
// GuidanceScale is the guidance strength; nil = backend default.
|
||||||
|
// Architecture-sensitive (distilled models want low or none), so prefer
|
||||||
|
// leaving it nil unless the caller knows the target model.
|
||||||
|
GuidanceScale *float64
|
||||||
|
|
||||||
|
// NegativePrompt steers generation away from concepts; "" = none.
|
||||||
|
NegativePrompt string
|
||||||
|
|
||||||
|
// Seed fixes the RNG seed for reproducible output; nil = random.
|
||||||
|
Seed *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the canonical video-generation result.
|
||||||
|
type Result struct {
|
||||||
|
// Video is the generated clip.
|
||||||
|
Video Video
|
||||||
|
|
||||||
|
// Raw is the provider-native response object, an escape hatch for
|
||||||
|
// provider-specific fields. May be nil; never required for normal use.
|
||||||
|
Raw any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option mutates a Request before it is sent. Options passed to Generate are
|
||||||
|
// applied to a copy of the request, so a Request value can be reused.
|
||||||
|
type Option func(*Request)
|
||||||
|
|
||||||
|
// WithInitImage conditions generation on a starting frame (image-to-video).
|
||||||
|
func WithInitImage(img Image) Option { return func(r *Request) { r.InitImage = &img } }
|
||||||
|
|
||||||
|
// WithSize sets the requested resolution (e.g. "1280x704").
|
||||||
|
func WithSize(size string) Option { return func(r *Request) { r.Size = size } }
|
||||||
|
|
||||||
|
// WithNumFrames sets the clip length in frames.
|
||||||
|
func WithNumFrames(n int) Option { return func(r *Request) { r.NumFrames = n } }
|
||||||
|
|
||||||
|
// WithFPS sets the frame rate of the generated clip.
|
||||||
|
func WithFPS(fps int) Option { return func(r *Request) { r.FPS = fps } }
|
||||||
|
|
||||||
|
// WithSteps overrides the number of diffusion steps.
|
||||||
|
func WithSteps(n int) Option { return func(r *Request) { r.Steps = &n } }
|
||||||
|
|
||||||
|
// WithGuidanceScale overrides the guidance strength.
|
||||||
|
func WithGuidanceScale(s float64) Option { return func(r *Request) { r.GuidanceScale = &s } }
|
||||||
|
|
||||||
|
// WithNegativePrompt sets a negative prompt.
|
||||||
|
func WithNegativePrompt(s string) Option { return func(r *Request) { r.NegativePrompt = s } }
|
||||||
|
|
||||||
|
// WithSeed fixes the RNG seed for reproducible output.
|
||||||
|
func WithSeed(seed int64) Option { return func(r *Request) { r.Seed = &seed } }
|
||||||
|
|
||||||
|
// Apply returns a copy of the request with all options applied. Providers call
|
||||||
|
// this once at the top of Generate.
|
||||||
|
func (r Request) Apply(opts ...Option) Request {
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&r)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model generates a video clip from a text prompt and optional conditioning
|
||||||
|
// frame. It is intentionally narrower than llm.Model — no Stream, no
|
||||||
|
// Capabilities, no tool calls.
|
||||||
|
type Model interface {
|
||||||
|
// Generate produces one clip for the request. Generation is slow
|
||||||
|
// (minutes on consumer hardware) and the call blocks until the clip is
|
||||||
|
// ready; callers bound it with a context deadline.
|
||||||
|
Generate(ctx context.Context, req Request, opts ...Option) (*Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelOption configures a Model at construction time (Provider.VideoModel).
|
||||||
|
// Reserved for future per-model settings; present now so the interface is
|
||||||
|
// forward-compatible.
|
||||||
|
type ModelOption func(*ModelConfig)
|
||||||
|
|
||||||
|
// ModelConfig carries per-model construction settings.
|
||||||
|
type ModelConfig struct{}
|
||||||
|
|
||||||
|
// ApplyModelOptions folds options into a config.
|
||||||
|
func ApplyModelOptions(opts []ModelOption) ModelConfig {
|
||||||
|
var cfg ModelConfig
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider mints video Models bound to one backend. It mirrors llm.Provider
|
||||||
|
// but for video generation.
|
||||||
|
type Provider interface {
|
||||||
|
// Name is the registry identifier for the provider.
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// VideoModel returns a Model bound to the given id (passed through to the
|
||||||
|
// backend verbatim; no catalog validation).
|
||||||
|
VideoModel(id string, opts ...ModelOption) (Model, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package videogen
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestApplyDoesNotMutateOriginal(t *testing.T) {
|
||||||
|
orig := Request{Prompt: "a cat"}
|
||||||
|
got := orig.Apply(
|
||||||
|
WithSize("1280x704"),
|
||||||
|
WithNumFrames(81),
|
||||||
|
WithFPS(16),
|
||||||
|
WithSteps(4),
|
||||||
|
WithGuidanceScale(1.0),
|
||||||
|
WithNegativePrompt("blurry"),
|
||||||
|
WithSeed(42),
|
||||||
|
WithInitImage(Image{MIME: "image/png", Data: []byte{1}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if orig.Size != "" || orig.NumFrames != 0 || orig.FPS != 0 || orig.Steps != nil ||
|
||||||
|
orig.GuidanceScale != nil || orig.NegativePrompt != "" || orig.Seed != nil || orig.InitImage != nil {
|
||||||
|
t.Fatalf("original mutated: %+v", orig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Size != "1280x704" || got.NumFrames != 81 || got.FPS != 16 {
|
||||||
|
t.Errorf("size/frames/fps = %q/%d/%d", got.Size, got.NumFrames, got.FPS)
|
||||||
|
}
|
||||||
|
if got.Steps == nil || *got.Steps != 4 {
|
||||||
|
t.Errorf("steps = %v", got.Steps)
|
||||||
|
}
|
||||||
|
if got.GuidanceScale == nil || *got.GuidanceScale != 1.0 {
|
||||||
|
t.Errorf("guidance = %v", got.GuidanceScale)
|
||||||
|
}
|
||||||
|
if got.NegativePrompt != "blurry" {
|
||||||
|
t.Errorf("negative = %q", got.NegativePrompt)
|
||||||
|
}
|
||||||
|
if got.Seed == nil || *got.Seed != 42 {
|
||||||
|
t.Errorf("seed = %v", got.Seed)
|
||||||
|
}
|
||||||
|
if got.InitImage == nil || got.InitImage.MIME != "image/png" {
|
||||||
|
t.Errorf("init image = %+v", got.InitImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyModelOptions(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
ApplyModelOptions([]ModelOption{func(*ModelConfig) { called = true }})
|
||||||
|
if !called {
|
||||||
|
t.Fatal("model option not applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user