Files
majordomo/provider/llamaswap/videochain.go
T
steveandClaude Fable 5 56b5b000a6
CI / Tidy (pull_request) Successful in 9m25s
CI / Build & Test (pull_request) Successful in 9m46s
fix: review findings — chain NaN/Inf + id hygiene, percent-escape jobPath, shared singleVideoResult
- SubmitChain rejects NaN/±Inf segment seconds with ErrUnsupported
  (previously an obscure json.Marshal error; NaN fails every comparison
  and +Inf passed the >= 0 check).
- ChainStatus skips segment entries with no usable id — JSON null
  (which no-op-unmarshals into a string, previously appending ""),
  empty strings, and id-less objects; the unfiltered list survives in
  Raw. ChainJob.SegmentIDs doc now also says ChainSegmentResult takes
  the segment index, not an id string.
- jobPath rejects '%' in job ids — %2F/%2E%2E percent-escapes decode
  back into path structure server-side, bypassing the literal check on
  this upstream-echoed value.
- singleVideoResult moves to video.go next to videoMIME, and the two
  remaining hand-rolled copies of the video-result validation
  (videoModel.Generate, Interpolate) now use it — one validation, one
  message shape.
- videogen.LipSyncer renamed to videogen.Lipsyncer for consistency with
  the rest of the surface's Lipsync* naming (LipsyncProvider,
  LipsyncModel, LipsyncRequest); not yet consumed downstream, so the
  rename is free now and never again.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WWCQcYStWXBYUy5sZWnbLT
2026-07-16 19:12:42 -04:00

218 lines
7.8 KiB
Go

// videochain.go implements videogen.ChainerProvider against the videoutils
// chain orchestrator reached through llama-swap's /upstream passthrough
// (ADR-0025):
//
// POST /upstream/<id>/v1/video/chain JSON submit -> {job_id}
// GET /upstream/<id>/v1/jobs/{id} -> {status,segment,total,segments}
// GET /upstream/<id>/v1/jobs/{id}/result -> encoded clip
// GET /upstream/<id>/v1/jobs/{id}/segments/{n} -> encoded clip
//
// Unlike the ACE-Step music path this client does NOT hide the job queue
// behind a blocking call: a chain runs through multiple GPU swaps for many
// minutes, and the caller (mort's long-video tool) owns the poll loop so it
// can deliver PARTIAL results — completed segments survive a mid-chain
// failure and stay fetchable via ChainSegmentResult.
package llamaswap
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
)
// ChainerModel implements videogen.ChainerProvider. The id selects which
// upstream llama-swap loads (the videoutils orchestrator, which in turn
// drives the generation model through llama-swap itself).
func (p *Provider) ChainerModel(id string, opts ...videogen.ChainerModelOption) (videogen.Chainer, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = videogen.ApplyChainerModelOptions(opts)
return &chainerModel{p: p, id: id}, nil
}
type chainerModel struct {
p *Provider
id string
}
// chainSubmitRequest is the videoutils POST /v1/video/chain shape. The init
// image rides as base64 in the JSON body (`init_image_b64`) — the submit is
// JSON, not multipart, per the pinned host contract.
type chainSubmitRequest struct {
Segments []chainSegmentWire `json:"segments"`
InitImageB64 string `json:"init_image_b64,omitempty"`
SmoothJoins bool `json:"smooth_joins,omitempty"`
Size string `json:"size,omitempty"`
}
type chainSegmentWire struct {
Prompt string `json:"prompt"`
Seconds float64 `json:"seconds,omitempty"`
}
// SubmitChain implements videogen.Chainer.
func (m *chainerModel) SubmitChain(ctx context.Context, req videogen.ChainRequest) (string, error) {
if len(req.Segments) == 0 {
return "", fmt.Errorf("%w: video chain requires at least one segment", llm.ErrUnsupported)
}
wire := chainSubmitRequest{
SmoothJoins: req.SmoothJoins,
Size: strings.TrimSpace(req.Size),
}
for i, seg := range req.Segments {
if strings.TrimSpace(seg.Prompt) == "" {
return "", fmt.Errorf("%w: video chain segment %d requires a prompt", llm.ErrUnsupported, i)
}
// NaN/±Inf would otherwise surface as an obscure json.Marshal error
// (NaN fails every comparison; +Inf passes the >= 0 check).
if seg.Seconds < 0 || math.IsNaN(seg.Seconds) || math.IsInf(seg.Seconds, 0) {
return "", fmt.Errorf("%w: video chain segment %d seconds must be a finite value >= 0, got %g", llm.ErrUnsupported, i, seg.Seconds)
}
wire.Segments = append(wire.Segments, chainSegmentWire{Prompt: seg.Prompt, Seconds: seg.Seconds})
}
if len(req.InitImage) > 0 {
wire.InitImageB64 = base64.StdEncoding.EncodeToString(req.InitImage)
}
path, err := upstreamPath(m.id, "/v1/video/chain")
if err != nil {
return "", err
}
// Tolerant envelope: {"job_id": ...} per the contract, with data-wrapped
// and bare-id fallbacks (musicgen release_task precedent).
var resp struct {
JobID string `json:"job_id"`
ID string `json:"id"`
Data struct {
JobID string `json:"job_id"`
ID string `json:"id"`
} `json:"data"`
}
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, &wire, &resp); err != nil {
return "", err
}
for _, id := range []string{resp.JobID, resp.Data.JobID, resp.ID, resp.Data.ID} {
if id != "" {
return id, nil
}
}
return "", &llm.APIError{Provider: m.p.name, Model: m.id, Message: "video chain submit returned no job_id"}
}
// chainJobResponse is the GET /v1/jobs/{id} shape. `segments` entries are
// tolerated as bare strings or objects keyed by id/segment_id.
type chainJobResponse struct {
Status string `json:"status"`
Segment int `json:"segment"`
Total int `json:"total"`
Segments []json.RawMessage `json:"segments"`
}
// ChainStatus implements videogen.Chainer.
func (m *chainerModel) ChainStatus(ctx context.Context, jobID string) (*videogen.ChainJob, error) {
path, err := m.jobPath(jobID, "")
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := m.p.doJSON(ctx, http.MethodGet, path, m.id, nil, &raw); err != nil {
return nil, err
}
var out chainJobResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("llama-swap: decode chain job status: %w", err)
}
job := &videogen.ChainJob{
Status: out.Status,
Segment: out.Segment,
Total: out.Total,
Raw: raw,
}
// Entries that carry no usable id — JSON null (which unmarshals into a
// string as a no-op, leaving ""), an empty string, or an object with
// neither key — are SKIPPED, never appended as "": SegmentIDs promises
// fetchable artifacts, and the full payload stays in Raw for callers
// that want the unfiltered list.
for _, entry := range out.Segments {
var s string
if json.Unmarshal(entry, &s) == nil {
if s != "" {
job.SegmentIDs = append(job.SegmentIDs, s)
}
continue
}
var obj struct {
ID string `json:"id"`
SegmentID string `json:"segment_id"`
}
if json.Unmarshal(entry, &obj) == nil {
switch {
case obj.ID != "":
job.SegmentIDs = append(job.SegmentIDs, obj.ID)
case obj.SegmentID != "":
job.SegmentIDs = append(job.SegmentIDs, obj.SegmentID)
}
}
}
if job.Status == "" {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "chain job status payload carried no status: " + truncateForError(raw)}
}
return job, nil
}
// ChainResult implements videogen.Chainer.
func (m *chainerModel) ChainResult(ctx context.Context, jobID string) (*videogen.Result, error) {
path, err := m.jobPath(jobID, "/result")
if err != nil {
return nil, err
}
raw, respType, err := m.p.doRaw(ctx, http.MethodGet, path, m.id, "", nil, maxVideoResponseBytes)
if err != nil {
return nil, err
}
return singleVideoResult(m.p.name, m.id, "video chain result", raw, respType)
}
// ChainSegmentResult implements videogen.Chainer.
func (m *chainerModel) ChainSegmentResult(ctx context.Context, jobID string, n int) (*videogen.Result, error) {
if n < 0 {
return nil, fmt.Errorf("%w: chain segment index must be >= 0, got %d", llm.ErrUnsupported, n)
}
path, err := m.jobPath(jobID, "/segments/"+strconv.Itoa(n))
if err != nil {
return nil, err
}
raw, respType, err := m.p.doRaw(ctx, http.MethodGet, path, m.id, "", nil, maxVideoResponseBytes)
if err != nil {
return nil, err
}
return singleVideoResult(m.p.name, m.id, "video chain segment", raw, respType)
}
// jobPath builds /upstream/<model>/v1/jobs/<jobID><suffix>, refusing job ids
// that carry path structure. The id is SERVER-SUPPLIED (echoed back from
// SubmitChain), so like upstreamPath's rest-component checks this rejects
// rather than escapes — a hostile/buggy upstream must not be able to steer
// the follow-up request at another proxy endpoint.
func (m *chainerModel) jobPath(jobID, suffix string) (string, error) {
if strings.TrimSpace(jobID) == "" {
return "", fmt.Errorf("llama-swap: chain job call requires a job id")
}
// '%' is rejected alongside the literal path characters: job ids never
// legitimately carry percent-escapes, and %2F/%2E%2E would decode back
// into path structure server-side.
if strings.ContainsAny(jobID, "/?#%") || strings.Contains(jobID, "..") {
return "", fmt.Errorf("llama-swap: invalid chain job id %q (contains path structure)", jobID)
}
return upstreamPath(m.id, "/v1/jobs/"+jobID+suffix)
}