diff --git a/meshgen/meshgen.go b/meshgen/meshgen.go index e8fa976..813a0b4 100644 --- a/meshgen/meshgen.go +++ b/meshgen/meshgen.go @@ -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) +} diff --git a/provider/llamaswap/mesh.go b/provider/llamaswap/mesh.go index ff8b44f..667c10e 100644 --- a/provider/llamaswap/mesh.go +++ b/provider/llamaswap/mesh.go @@ -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. diff --git a/provider/llamaswap/mesh_convert.go b/provider/llamaswap/mesh_convert.go new file mode 100644 index 0000000..5b3d905 --- /dev/null +++ b/provider/llamaswap/mesh_convert.go @@ -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 +} diff --git a/provider/llamaswap/music.go b/provider/llamaswap/music.go index cd383b9..b0f2ceb 100644 --- a/provider/llamaswap/music.go +++ b/provider/llamaswap/music.go @@ -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))} } diff --git a/provider/llamaswap/music_embed_test.go b/provider/llamaswap/music_embed_test.go index 5d15019..24e9475 100644 --- a/provider/llamaswap/music_embed_test.go +++ b/provider/llamaswap/music_embed_test.go @@ -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) + } +}