From 44fcfbb2731db605b5bf959d9f15c48431f946a9 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 8 Aug 2026 02:47:39 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(videogen):=20LastImage=20=E2=80=94=20p?= =?UTF-8?q?in=20the=20trailing=20keyframe=20(first-last-frame-to-video)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit videogen.Request gains LastImage alongside InitImage, so one Request covers t2v, i2v and FL2V without a mode flag. With InitImage it pins both ends of the clip; alone it pins the destination and lets the backend invent the approach. The llamaswap provider sends it as a SEPARATE `input_reference_last` part rather than a second `input_reference`. Multipart permits repeated names, but then which frame is first and which is last depends on part ORDER — an ordering contract invisible in the payload, that nothing notices breaking. A backend that does not know the new name ignores the part, the same degradation as any other unknown field. Both parts go through one writeImagePart helper so their encoding cannot drift, and an empty LastImage is rejected up front exactly as InitImage already is. Support is per-model and deliberately NOT advertised in this contract: a backend that ignores a trailing keyframe returns an ordinary clip, which is indistinguishable from success. The doc comment says so, because a caller that needs to know whether the pin took effect has to establish that out of band — and the mort side gates on a convar for exactly this reason. Motivated by mort's #1567 (long-form video): with both ends pinned, drift becomes structurally bounded inside each shot instead of compounding across an autoregressive chain. Tests break-checked: sending the last frame under the shared name fails both the distinct-name assertion and the last-alone case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PLjgrxvHjm1sJgUu9zBPH9 --- provider/llamaswap/video.go | 34 +++++++-- provider/llamaswap/video_test.go | 116 +++++++++++++++++++++++++++++++ videogen/videogen.go | 18 +++++ 3 files changed, 163 insertions(+), 5 deletions(-) diff --git a/provider/llamaswap/video.go b/provider/llamaswap/video.go index 23f21ef..d61ae34 100644 --- a/provider/llamaswap/video.go +++ b/provider/llamaswap/video.go @@ -56,6 +56,9 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts .. if req.InitImage != nil && len(req.InitImage.Data) == 0 { return nil, fmt.Errorf("%w: video init image has no bytes", llm.ErrUnsupported) } + if req.LastImage != nil && len(req.LastImage.Data) == 0 { + return nil, fmt.Errorf("%w: video last image has no bytes", llm.ErrUnsupported) + } width, height, err := parseSize(req.Size) if err != nil { return nil, fmt.Errorf("%w: %v", llm.ErrUnsupported, err) @@ -83,12 +86,19 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts .. 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 := writeImagePart(w, "input_reference", req.InitImage); err != nil { + return nil, err } - if _, err := fw.Write(req.InitImage.Data); err != nil { - return nil, fmt.Errorf("llama-swap: build video form: %w", err) + } + // The trailing keyframe rides a SEPARATE part rather than a second + // `input_reference`: multipart permits repeated names, but the receiving + // end would then have to rely on part ORDER to tell first from last, and + // an ordering contract that is invisible in the field name is one nobody + // can see they have broken. A backend that does not know the name ignores + // the part, which is the same degradation as any other unknown field. + if req.LastImage != nil { + if err := writeImagePart(w, "input_reference_last", req.LastImage); err != nil { + return nil, err } } if err := w.Close(); err != nil { @@ -141,6 +151,20 @@ func initImageFilename(mimeType string) string { return imageFilename(mimeType, "frame") } +// writeImagePart attaches one conditioning frame under the given field name. +// Shared by the first- and last-frame parts so the two cannot drift in how +// they encode, which is the usual way a second copy of a block goes wrong. +func writeImagePart(w *multipart.Writer, field string, img *videogen.Image) error { + fw, err := w.CreateFormFile(field, initImageFilename(img.MIME)) + if err != nil { + return fmt.Errorf("llama-swap: build video form: %w", err) + } + if _, err := fw.Write(img.Data); err != nil { + return fmt.Errorf("llama-swap: build video form: %w", err) + } + return nil +} + // formatInt renders an optional int pointer for a form field; nil = "" (omit). func formatInt(v *int) string { if v == nil { diff --git a/provider/llamaswap/video_test.go b/provider/llamaswap/video_test.go index 9958b00..7ebb264 100644 --- a/provider/llamaswap/video_test.go +++ b/provider/llamaswap/video_test.go @@ -223,3 +223,119 @@ func TestVideoGenerateNonVideoBodyErrors(t *testing.T) { 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 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, _, err := r.FormFile("input_reference"); err == nil { + gotFirst, _ = io.ReadAll(f) + f.Close() + } + if f, _, err := r.FormFile("input_reference_last"); err == nil { + sawLastPart = true + gotLast, _ = 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-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") + } +} + +// 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) + } +} diff --git a/videogen/videogen.go b/videogen/videogen.go index c20ff32..f5f16f2 100644 --- a/videogen/videogen.go +++ b/videogen/videogen.go @@ -12,6 +12,8 @@ // 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. +// LastImage extends the same surface to the other end of the clip, so one +// Request covers t2v, i2v, and first-last-frame-to-video without a mode flag. // // The first implementation is provider/llamaswap, which targets the blocking // OpenAI/vLLM-Omni-style POST /v1/videos/sync endpoint: the response body is @@ -51,6 +53,18 @@ type Request struct { // nil = pure text-to-video. InitImage *Image + // LastImage conditions generation on an ENDING frame. With InitImage it + // pins both ends (first-last-frame-to-video); alone it pins only the + // destination and lets the backend invent the approach. + // + // Support is per-model and NOT advertised anywhere in this contract: a + // backend that does not understand a trailing keyframe ignores it and + // returns an ordinary clip, which is indistinguishable from success. A + // caller that needs to know whether the pin took effect must establish + // that out of band — see the note on LastImage support in + // provider/llamaswap. + LastImage *Image + // Size is the requested resolution, e.g. "1280x704"; "" = backend default. Size string @@ -92,6 +106,10 @@ 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 } } +// WithLastImage conditions generation on an ending frame. Combined with +// WithInitImage this pins both ends of the clip. +func WithLastImage(img Image) Option { return func(r *Request) { r.LastImage = &img } } + // WithSize sets the requested resolution (e.g. "1280x704"). func WithSize(size string) Option { return func(r *Request) { r.Size = size } } From dbc96898abaf12e164cc0b9173f6cb0538db7320 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 8 Aug 2026 02:50:43 -0400 Subject: [PATCH 2/4] fix(videogen): distinct FILENAMES for the two keyframes, not just distinct field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01PLjgrxvHjm1sJgUu9zBPH9 --- provider/llamaswap/video.go | 23 ++++++++++++++++------- provider/llamaswap/video_test.go | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/provider/llamaswap/video.go b/provider/llamaswap/video.go index d61ae34..d9e3308 100644 --- a/provider/llamaswap/video.go +++ b/provider/llamaswap/video.go @@ -86,7 +86,7 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts .. return nil, err } if req.InitImage != nil { - if err := writeImagePart(w, "input_reference", req.InitImage); err != nil { + if err := writeImagePart(w, "input_reference", "frame", req.InitImage); err != nil { return nil, err } } @@ -97,7 +97,7 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts .. // can see they have broken. A backend that does not know the name ignores // the part, which is the same degradation as any other unknown field. if req.LastImage != nil { - if err := writeImagePart(w, "input_reference_last", req.LastImage); err != nil { + if err := writeImagePart(w, "input_reference_last", "frame_last", req.LastImage); err != nil { return nil, err } } @@ -151,11 +151,20 @@ func initImageFilename(mimeType string) string { return imageFilename(mimeType, "frame") } -// writeImagePart attaches one conditioning frame under the given field name. -// Shared by the first- and last-frame parts so the two cannot drift in how -// they encode, which is the usual way a second copy of a block goes wrong. -func writeImagePart(w *multipart.Writer, field string, img *videogen.Image) error { - fw, err := w.CreateFormFile(field, initImageFilename(img.MIME)) +// writeImagePart attaches one conditioning frame under the given field name, +// with a filename derived from nameStem. Shared by the first- and last-frame +// parts so the two cannot drift in how they encode, which is the usual way a +// second copy of a block goes wrong. +// +// The two frames MUST carry DISTINCT filenames, not merely distinct field +// names. Backends commonly stage an uploaded frame under a name derived from +// the filename — our own ComfyUI shim posts to /upload/image with +// overwrite=true — so two parts sharing "frame.png" would have the second +// clobber the first, and BOTH keyframe inputs would then resolve to the same +// stored image. The clip would render clean, pinned at both ends to the same +// frame, with nothing anywhere reporting a problem. +func writeImagePart(w *multipart.Writer, field, nameStem string, img *videogen.Image) error { + fw, err := w.CreateFormFile(field, imageFilename(img.MIME, nameStem)) if err != nil { return fmt.Errorf("llama-swap: build video form: %w", err) } diff --git a/provider/llamaswap/video_test.go b/provider/llamaswap/video_test.go index 7ebb264..81a6ae5 100644 --- a/provider/llamaswap/video_test.go +++ b/provider/llamaswap/video_test.go @@ -233,19 +233,22 @@ func TestVideoGenerateNonVideoBodyErrors(t *testing.T) { // 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, _, err := r.FormFile("input_reference"); err == nil { + if f, hdr, err := r.FormFile("input_reference"); err == nil { gotFirst, _ = io.ReadAll(f) + firstName = hdr.Filename f.Close() } - if f, _, err := r.FormFile("input_reference_last"); err == nil { + 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") @@ -283,6 +286,18 @@ func TestVideoGenerateSendsBothKeyframes(t *testing.T) { 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 From 588e0924653d0411bd689eb65a98358c023256a0 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 8 Aug 2026 02:59:12 -0400 Subject: [PATCH 3/4] =?UTF-8?q?docs(videogen):=20gadfly=20=E2=80=94=20READ?= =?UTF-8?q?ME=20FL2V=20section,=20and=20stop=20pointing=20at=20a=20note=20?= =?UTF-8?q?that=20does=20not=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README documented only t2v/i2v. Now a table of the four keyframe combinations, plus the undetectable-support caveat, which is the one thing a caller cannot work out for itself. - The LastImage doc comment said "see the note on LastImage support in provider/llamaswap" — there was no such note. A pointer to something that does not exist is worse than no pointer; the comment is now self-contained. - Generate's doc described only input_reference; it now names input_reference_last and explains why an unsupporting backend returns a clip rather than an error. The 2/4 finding (writeImagePart reusing the "frame" base for both parts) was already fixed in dbc9689 — from the receiving end, where the consequence is concrete rather than stylistic: ComfyUI stages uploads by FILENAME with overwrite=true, so a shared name means the second clobbers the first and both keyframes resolve to one image. Not taken: initImageFilename's name is no longer misleading (writeImagePart stopped calling it), and it is still used by lipsync.go so it is not dead. The empty-LastImage test stays standalone — it mirrors the existing standalone empty-InitImage coverage rather than a table this file does not have. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PLjgrxvHjm1sJgUu9zBPH9 --- README.md | 23 ++++++++++++++++++----- provider/llamaswap/video.go | 9 ++++++--- videogen/videogen.go | 9 +++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 85ff031..0ecaf6b 100644 --- a/README.md +++ b/README.md @@ -272,17 +272,30 @@ tr, err := tm.Transcribe(ctx, audio.TranscriptionRequest{ voices, err := ls.ListVoices(ctx, "kokoro") // []string of voice ids ``` -## Video: text-to-video + image-to-video +## Video: text-to-video, image-to-video, first-last-frame 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 +defaults, bytes in/out. All modes are one surface, selected by which +keyframes are set rather than by a mode flag: + +| `InitImage` | `LastImage` | mode | +|---|---|---| +| nil | nil | text-to-video | +| set | nil | image-to-video (hybrid checkpoints like Wan 2.2 TI2V serve both) | +| set | set | first-last-frame — both ends pinned | +| nil | set | pin the destination, model invents the approach | + +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. +**`LastImage` support is per-model and cannot be detected.** A backend that +does not understand a trailing keyframe ignores the part and returns an +ordinary clip — indistinguishable from success. There is no capability bit, +because the contract has no way to learn one, so a caller depending on the +pin must establish support out of band. + ```go vm, _ := ls.VideoModel("videogen-wan22-5b") res, err := vm.Generate(ctx, videogen.Request{Prompt: "a cat surfing"}, diff --git a/provider/llamaswap/video.go b/provider/llamaswap/video.go index d9e3308..f74a35b 100644 --- a/provider/llamaswap/video.go +++ b/provider/llamaswap/video.go @@ -38,10 +38,13 @@ type videoModel struct { // 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 +// num_inference_steps, guidance_scale); the leading conditioning frame is sent +// as an `input_reference` file part, following OpenAI's videos API, and a +// trailing keyframe (Request.LastImage) as `input_reference_last`. Upstreams // ignore fields they don't understand, and optional fields stay off the wire -// entirely so the model's own defaults apply. +// entirely so the model's own defaults apply — which is also why a backend +// without first-last-frame support returns an ordinary clip here rather than +// an error. 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) == "" { diff --git a/videogen/videogen.go b/videogen/videogen.go index f5f16f2..5dec93c 100644 --- a/videogen/videogen.go +++ b/videogen/videogen.go @@ -59,10 +59,11 @@ type Request struct { // // Support is per-model and NOT advertised anywhere in this contract: a // backend that does not understand a trailing keyframe ignores it and - // returns an ordinary clip, which is indistinguishable from success. A - // caller that needs to know whether the pin took effect must establish - // that out of band — see the note on LastImage support in - // provider/llamaswap. + // returns an ordinary clip, which is indistinguishable from success. + // There is no capability bit to consult, because the contract has no way + // to learn one. A caller that needs to know whether the pin actually took + // effect must establish that out of band — by configuration it controls, + // not by inspecting the result. LastImage *Image // Size is the requested resolution, e.g. "1280x704"; "" = backend default. From 5994d9692105f06c4752f028603b0980018dc6b8 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 8 Aug 2026 03:06:21 -0400 Subject: [PATCH 4/4] =?UTF-8?q?refactor(llamaswap):=20drop=20initImageFile?= =?UTF-8?q?name=20=E2=80=94=20one=20caller=20left,=20and=20it=20was=20a=20?= =?UTF-8?q?rename=20of=20imageFilename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2/4 finding is right: after writeImagePart started passing an explicit filename stem, initImageFilename had no caller in video.go, and its "conditioning frame" doc no longer described its one remaining user (lipsync.go's avatar image). A one-line wrapper that survives only to be misdescribed is not indirection worth keeping. lipsync now calls imageFilename(mime, "frame") directly, and imageFilename's doc lists the real bases — including WHY the video keyframes need distinct ones: a backend that stages uploads by filename would otherwise have the second overwrite the first. Not taken: consolidating the first/last-frame rationale to a single canonical site. The copies address different readers — the wire encoding (provider), the contract's undetectable-support caveat (videogen), and the mode table (README) — and last round's finding was a doc pointing at a note that did not exist. Trading duplication for cross-references is what produced that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PLjgrxvHjm1sJgUu9zBPH9 --- provider/llamaswap/faceswap.go | 8 +++++--- provider/llamaswap/lipsync.go | 2 +- provider/llamaswap/video.go | 7 ------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/provider/llamaswap/faceswap.go b/provider/llamaswap/faceswap.go index 8434ce8..294a765 100644 --- a/provider/llamaswap/faceswap.go +++ b/provider/llamaswap/faceswap.go @@ -207,10 +207,12 @@ func parseSwapReport(header string) []imagegen.SwappedFace { // 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. +// ("target"/"source", "frame"/"frame_last") so a log line says which one was +// malformed — and, for the video keyframes, so a backend that stages uploads +// by filename cannot have the second overwrite the first. // -// initImageFilename (video.go) is this function with base fixed to "frame" -// and delegates here — two copies of one extension table is how they drift. +// Every caller routes through here: two copies of one extension table is how +// they drift. func imageFilename(mimeType, base string) string { if base == "" { base = "image" diff --git a/provider/llamaswap/lipsync.go b/provider/llamaswap/lipsync.go index 279aa3b..2d594c8 100644 --- a/provider/llamaswap/lipsync.go +++ b/provider/llamaswap/lipsync.go @@ -56,7 +56,7 @@ func (m *lipsyncModel) Lipsync(ctx context.Context, req videogen.LipsyncRequest, // hand (mirrors videoModel.Generate). var buf bytes.Buffer w := multipart.NewWriter(&buf) - fw, err := w.CreateFormFile("image", initImageFilename(req.Image.MIME)) + fw, err := w.CreateFormFile("image", imageFilename(req.Image.MIME, "frame")) if err != nil { return nil, fmt.Errorf("llama-swap: build lipsync form: %w", err) } diff --git a/provider/llamaswap/video.go b/provider/llamaswap/video.go index f74a35b..a694977 100644 --- a/provider/llamaswap/video.go +++ b/provider/llamaswap/video.go @@ -147,13 +147,6 @@ func singleVideoResult(provider, model, verb string, raw []byte, contentType str 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") -} - // writeImagePart attaches one conditioning frame under the given field name, // with a filename derived from nameStem. Shared by the first- and last-frame // parts so the two cannot drift in how they encode, which is the usual way a