4a752ffa2a
- upstreamPath rejects '..' in model ids AND in the rest path — the rest can embed SERVER-SUPPLIED components (ACE-Step result file URLs), so dot-dot/scheme smuggling toward other proxy endpoints is refused - singleImageResult requires positive image evidence (sniffed magic OR declared image/*): an empty-Content-Type error page can no longer pass as 'the image' via sniffImageMIME's PNG-default labelling - upscale/background responses get a dedicated 256MB cap (the 64MB cap is JSON-sized; a 4x PNG legitimately exceeds it) - mesh JSON-detection widened (512-byte whitespace-tolerant peek + reject declared application/json) - Transcribe now reuses buildMultipart; transcriptionFilename takes (filename, mime) so diarize shares it without a fake request struct; truncateForError stops shadowing builtin cap; OnlyMask doc de-ambiguated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.9 KiB
Go
137 lines
4.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 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)}
|
|
}
|
|
return &meshgen.Result{Mesh: meshgen.Mesh{Data: raw, Format: format, MIME: mimeType}}, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|