Files
majordomo/provider/llamaswap/mesh.go
T
steveandClaude Fable 5 fd8d56c3c1
Gadfly review (reusable) / review (pull_request) Successful in 15s
Adversarial Review (Gadfly) / review (pull_request) Successful in 15s
CI / Tidy (pull_request) Successful in 9m26s
CI / Build & Test (pull_request) Successful in 10m0s
fix: live-API corrections — music result shapes, mesh format honesty, mesh conversion
Round 2 from live smokes on netherstorm (2026-07-14):

- ACE-Step result blob is an ARRAY of objects and carries RAW control
  characters inside string values (literal newlines) — strict JSON
  rejected it. parseMusicResult sanitizes control chars (only legal
  inside string values in the double-encoded blob) and accepts array or
  object shapes. Regression test uses the live payload shape.
- Hunyuan3D GenerationRequest has NO output-format field (the documented
  type param is fiction) — it always returns GLB. Results are now
  labelled by sniffed magic bytes, never by the requested format.
- NEW meshgen.Converter/ConverterProvider optional surface + llamaswap
  impl over the mediautils shim POST /v1/convert_mesh — the STL hop for
  the printer pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-14 12:22:06 -04:00

158 lines
5.9 KiB
Go

// mesh.go implements meshgen.Provider against a Hunyuan3D-2.1-style
// api_server reached through llama-swap's /upstream passthrough (ADR-0020):
//
// POST /upstream/<id>/generate JSON {image: <b64>, type: "glb"|"stl"|...}
//
// The response body IS the encoded mesh (binary, Content-Type
// application/octet-stream), so one request yields exactly one asset.
package llamaswap
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/meshgen"
)
// maxMeshResponseBytes caps the /generate body — a high-resolution textured
// mesh legitimately passes the 64MB JSON cap, but stays far under video
// scale.
const maxMeshResponseBytes = 256 << 20
// MeshModel implements meshgen.Provider. The id selects which upstream
// llama-swap loads.
func (p *Provider) MeshModel(id string, opts ...meshgen.ModelOption) (meshgen.Model, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = meshgen.ApplyModelOptions(opts)
return &meshModel{p: p, id: id}, nil
}
type meshModel struct {
p *Provider
id string
}
// hunyuanGenerateRequest is the Hunyuan3D api_server /generate shape.
// Optional fields are pointers/omitempty so unset values fall back to the
// server's defaults (mirrors the sd-server wire structs). Type is sent for
// forward-compat but the LIVE server's GenerationRequest has no such field
// and always returns GLB (verified 2026-07-14) — the response format is
// therefore SNIFFED, and non-GLB output is the caller's conversion problem
// (meshgen.Converter / the mediautils shim).
type hunyuanGenerateRequest struct {
Image string `json:"image"`
Type string `json:"type,omitempty"`
Texture bool `json:"texture"`
RemoveBackground *bool `json:"remove_background,omitempty"`
OctreeResolution int `json:"octree_resolution,omitempty"`
NumInferenceSteps *int `json:"num_inference_steps,omitempty"`
GuidanceScale *float64 `json:"guidance_scale,omitempty"`
Seed *int64 `json:"seed,omitempty"`
FaceCount int `json:"face_count,omitempty"`
}
// meshFormats maps the supported output containers to MIME types.
var meshFormats = map[string]string{
"glb": "model/gltf-binary",
"stl": "model/stl",
"obj": "model/obj",
}
// Generate implements meshgen.Model.
func (m *meshModel) Generate(ctx context.Context, req meshgen.Request, opts ...meshgen.Option) (*meshgen.Result, error) {
req = req.Apply(opts...)
if len(req.Image.Data) == 0 {
return nil, fmt.Errorf("%w: mesh generation requires an image", llm.ErrUnsupported)
}
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
format = "glb"
}
mimeType, ok := meshFormats[format]
if !ok {
return nil, fmt.Errorf("%w: unsupported mesh format %q (want glb, stl, or obj)", llm.ErrUnsupported, req.Format)
}
if req.OctreeResolution < 0 || req.FaceCount < 0 {
return nil, fmt.Errorf("%w: octree resolution and face count must be >= 0", llm.ErrUnsupported)
}
path, err := upstreamPath(m.id, "/generate")
if err != nil {
return nil, err
}
wire := hunyuanGenerateRequest{
Image: base64.StdEncoding.EncodeToString(req.Image.Data),
Type: format,
Texture: req.Texture,
RemoveBackground: req.RemoveBackground,
OctreeResolution: req.OctreeResolution,
NumInferenceSteps: req.Steps,
GuidanceScale: req.GuidanceScale,
Seed: req.Seed,
FaceCount: req.FaceCount,
}
// doRaw + manual JSON encode (not doJSON): the SUCCESS body is binary
// mesh bytes, not JSON.
encoded, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("llama-swap: encode mesh request: %w", err)
}
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, "application/json", bytes.NewReader(encoded), maxMeshResponseBytes)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "mesh response contained no data"}
}
// A JSON body on the success path is the server reporting a soft error
// (or an API drift) — never hand it back as "the mesh". Two signals:
// the declared Content-Type, and a whitespace-tolerant peek at the
// leading bytes (512 covers any indented error envelope; a binary STL
// header theoretically CAN start with '{', but a real one also won't
// be all-whitespace-then-brace).
if strings.Contains(strings.ToLower(respType), "application/json") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "mesh response is JSON, not mesh bytes: " + truncateForError(raw)}
}
if first := strings.TrimLeft(string(raw[:min(len(raw), 512)]), " \t\r\n"); strings.HasPrefix(first, "{") || strings.HasPrefix(first, "[") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "mesh response is JSON, not mesh bytes: " + truncateForError(raw)}
}
// Label the result by what the bytes ARE, not what was requested: the
// live server ignores the format field entirely.
actualFormat, actualMIME := sniffMeshFormat(raw, format, mimeType)
return &meshgen.Result{Mesh: meshgen.Mesh{Data: raw, Format: actualFormat, MIME: actualMIME}}, nil
}
// sniffMeshFormat identifies the mesh container from magic bytes, falling
// back to the requested format only when the bytes are ambiguous (binary
// STL has no magic).
func sniffMeshFormat(raw []byte, requested, requestedMIME string) (string, string) {
switch {
case len(raw) >= 4 && string(raw[:4]) == "glTF":
return "glb", meshFormats["glb"]
case len(raw) >= 6 && strings.EqualFold(string(raw[:6]), "solid "):
return "stl", meshFormats["stl"]
default:
return requested, requestedMIME
}
}
// truncateForError bounds a payload quoted into an error message.
func truncateForError(b []byte) string {
const maxErrLen = 500
if len(b) > maxErrLen {
return string(b[:maxErrLen]) + "..."
}
return string(b)
}