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]>
This commit is contained in:
@@ -150,3 +150,23 @@ type Provider interface {
|
||||
// backend verbatim; no catalog validation).
|
||||
MeshModel(id string, opts ...ModelOption) (Model, error)
|
||||
}
|
||||
|
||||
// Converter re-encodes a mesh between containers (GLB/STL/OBJ). A separate
|
||||
// optional surface because conversion typically runs on a DIFFERENT backend
|
||||
// than generation (the reference host converts on its mediautils shim —
|
||||
// Hunyuan3D's live server always emits GLB regardless of the requested
|
||||
// format).
|
||||
type Converter interface {
|
||||
// Convert returns the mesh re-encoded in the given format.
|
||||
Convert(ctx context.Context, mesh Mesh, format string) (*Result, error)
|
||||
}
|
||||
|
||||
// ConverterProvider mints Converters bound to one backend.
|
||||
type ConverterProvider interface {
|
||||
// Name is the registry identifier for the provider.
|
||||
Name() string
|
||||
|
||||
// MeshConverter returns a Converter bound to the given id (passed
|
||||
// through to the backend verbatim; no catalog validation).
|
||||
MeshConverter(id string) (Converter, error)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ type meshModel struct {
|
||||
|
||||
// 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).
|
||||
// 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"`
|
||||
@@ -123,7 +127,24 @@ func (m *meshModel) Generate(ctx context.Context, req meshgen.Request, opts ...m
|
||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
||||
Message: "mesh response is JSON, not mesh bytes: " + truncateForError(raw)}
|
||||
}
|
||||
return &meshgen.Result{Mesh: meshgen.Mesh{Data: raw, Format: format, MIME: mimeType}}, nil
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// mesh_convert.go implements meshgen.ConverterProvider against the
|
||||
// mediautils shim's POST /v1/convert_mesh (multipart file + target),
|
||||
// reached through the /upstream passthrough (ADR-0020). Exists because
|
||||
// Hunyuan3D's api_server always returns GLB — STL for the printer
|
||||
// pipeline is produced by this conversion hop.
|
||||
package llamaswap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/meshgen"
|
||||
)
|
||||
|
||||
// MeshConverter implements meshgen.ConverterProvider.
|
||||
func (p *Provider) MeshConverter(id string) (meshgen.Converter, error) {
|
||||
if err := p.requireBaseURL(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meshConverter{p: p, id: id}, nil
|
||||
}
|
||||
|
||||
type meshConverter struct {
|
||||
p *Provider
|
||||
id string
|
||||
}
|
||||
|
||||
// Convert implements meshgen.Converter.
|
||||
func (m *meshConverter) Convert(ctx context.Context, mesh meshgen.Mesh, format string) (*meshgen.Result, error) {
|
||||
if len(mesh.Data) == 0 {
|
||||
return nil, fmt.Errorf("%w: mesh conversion requires mesh bytes", llm.ErrUnsupported)
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
mimeType, ok := meshFormats[format]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: unsupported mesh format %q (want glb, stl, or obj)", llm.ErrUnsupported, format)
|
||||
}
|
||||
path, err := upstreamPath(m.id, "/v1/convert_mesh")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename := "mesh." + mesh.Format
|
||||
if mesh.Format == "" {
|
||||
filename = "mesh"
|
||||
}
|
||||
body, contentType, err := buildMultipart("build mesh-convert form",
|
||||
filePart{field: "file", filename: filename, data: mesh.Data},
|
||||
[]formField{{"target", format, true}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, _, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxMeshResponseBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "mesh conversion returned no data"}
|
||||
}
|
||||
actualFormat, actualMIME := sniffMeshFormat(raw, format, mimeType)
|
||||
return &meshgen.Result{Mesh: meshgen.Mesh{Data: raw, Format: actualFormat, MIME: actualMIME}}, nil
|
||||
}
|
||||
@@ -72,6 +72,36 @@ type queryItem struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// musicResult is the useful subset of ACE-Step's double-encoded result
|
||||
// blob.
|
||||
type musicResult struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// parseMusicResult decodes the `result` string, tolerating two live-API
|
||||
// quirks (observed 2026-07-14): the payload is an ARRAY of result objects
|
||||
// (not a bare object), and string values can contain RAW control
|
||||
// characters (a literal newline in timing fields) that strict JSON
|
||||
// rejects. Control chars can only legally sit inside string values in the
|
||||
// double-encoded blob, so replacing them with spaces preserves structure.
|
||||
func parseMusicResult(blob string) (musicResult, bool) {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, blob)
|
||||
var arr []musicResult
|
||||
if err := json.Unmarshal([]byte(sanitized), &arr); err == nil && len(arr) > 0 {
|
||||
return arr[0], true
|
||||
}
|
||||
var one musicResult
|
||||
if err := json.Unmarshal([]byte(sanitized), &one); err == nil {
|
||||
return one, true
|
||||
}
|
||||
return musicResult{}, false
|
||||
}
|
||||
|
||||
// musicFormatMIME resolves the clip MIME from the response Content-Type
|
||||
// or the requested format, reusing speechMIME's table (one format->MIME
|
||||
// map for the whole provider). wav32 is ACE-Step-specific: normalize it
|
||||
@@ -232,10 +262,8 @@ func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
|
||||
|
||||
// fetchResult downloads the finished clip named by the job's result blob.
|
||||
func (m *musicModel) fetchResult(ctx context.Context, format string, item *queryItem) (*musicgen.Result, error) {
|
||||
var result struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(item.Result), &result); err != nil || result.File == "" {
|
||||
result, ok := parseMusicResult(item.Result)
|
||||
if !ok || result.File == "" {
|
||||
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
||||
Message: "music result blob missing file URL: " + truncateForError([]byte(item.Result))}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/embeddings"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/meshgen"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/musicgen"
|
||||
)
|
||||
|
||||
@@ -293,3 +294,75 @@ func TestMusicGenerateRejectsHostileFileURL(t *testing.T) {
|
||||
t.Fatal("dot-dot result file URL accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMusicResultLiveShapes(t *testing.T) {
|
||||
// Live ACE-Step (2026-07-14): result is an ARRAY and carries raw
|
||||
// control characters inside string values.
|
||||
arrayWithCtrl := "[{\"file\": \"/v1/audio?path=x.mp3\", \"prompt\": \"line1\nline2\"}]"
|
||||
res, ok := parseMusicResult(arrayWithCtrl)
|
||||
if !ok || res.File != "/v1/audio?path=x.mp3" {
|
||||
t.Fatalf("array+ctrl: ok=%v res=%+v", ok, res)
|
||||
}
|
||||
// Docs shape (bare object) still parses.
|
||||
res, ok = parseMusicResult(`{"file": "/v1/audio?path=y.mp3"}`)
|
||||
if !ok || res.File != "/v1/audio?path=y.mp3" {
|
||||
t.Fatalf("object: ok=%v res=%+v", ok, res)
|
||||
}
|
||||
if _, ok := parseMusicResult("not json"); ok {
|
||||
t.Fatal("garbage parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshResultSniffsActualFormat(t *testing.T) {
|
||||
// Live Hunyuan3D always returns GLB regardless of the requested
|
||||
// format — the result must be labelled by its magic bytes.
|
||||
glb := append([]byte("glTF"), []byte("\x02\x00\x00\x00rest")...)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(glb)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
mm, _ := p.MeshModel("image3d-hunyuan21")
|
||||
res, err := mm.Generate(context.Background(),
|
||||
meshgen.Request{Image: meshgen.Image{Data: []byte{1}}, Format: "stl"})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if res.Mesh.Format != "glb" || res.Mesh.MIME != "model/gltf-binary" {
|
||||
t.Fatalf("mesh labelled %s/%s, want glb (sniffed)", res.Mesh.Format, res.Mesh.MIME)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshConverter(t *testing.T) {
|
||||
stl := []byte("solid m\nendsolid m\n")
|
||||
var gotTarget, gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("multipart: %v", err)
|
||||
}
|
||||
gotTarget = r.FormValue("target")
|
||||
w.Header().Set("Content-Type", "model/stl")
|
||||
_, _ = w.Write(stl)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
mc, err := p.MeshConverter("mediautils")
|
||||
if err != nil {
|
||||
t.Fatalf("MeshConverter: %v", err)
|
||||
}
|
||||
res, err := mc.Convert(context.Background(),
|
||||
meshgen.Mesh{Data: []byte("glTFxxxx"), Format: "glb"}, "stl")
|
||||
if err != nil {
|
||||
t.Fatalf("Convert: %v", err)
|
||||
}
|
||||
if gotPath != "/upstream/mediautils/v1/convert_mesh" || gotTarget != "stl" {
|
||||
t.Errorf("path/target = %q/%q", gotPath, gotTarget)
|
||||
}
|
||||
if res.Mesh.Format != "stl" {
|
||||
t.Errorf("format = %q", res.Mesh.Format)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user