feat: Add 3d generation UI/API and trellis2cpp backend (#10979)

* feat(3d): add Generate3D RPC, FLAG_3D capability, and /v1/3d/generations endpoint

Adds the plumbing for image-conditioned 3D asset generation (binary
glTF / GLB output), modeled on the video generation path:

- backend.proto: Generate3D RPC + Generate3DRequest (staged image src,
  glb dst, seed/step/cfg_scale/texture_steps, quality and background
  enums, params map for backend-specific extras)
- pkg/grpc: thread Generate3D through client, server, embed, base and
  the backend interfaces; connection-evicting and distributed-node
  wrappers (in-flight tracking + file staging) included
- core/config: FLAG_3D usecase (guessed only for the trellis2cpp
  backend), '3d' canonical usecase string mapped to the Generate3D
  method, and a '3d' output modality
- REST: POST /v1/3d/generations (+ unversioned alias) returning
  OpenAIResponse with a /generated-3d URL or b64_json; conditioning
  image accepted as URL, base64, or data URI; quality/background
  validated at the edge; .glb served as model/gltf-binary
- auth: '3d' route feature (default ON); /api/instructions entry

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(trellis2cpp): add the trellis2.cpp image-to-3D backend

Wraps localai-org/trellis2cpp (C++/GGML port of Microsoft TRELLIS.2,
pbr-textures branch) as a Go+purego backend, following the
stablediffusion-ggml pattern:

- backend/go/trellis2cpp: purego bindings to the flat C ABI (v9,
  asserted at startup), eager pipeline load with model-set validation
  (refuses non-trellis GGUFs; degrades coarse/geometry-only/textured
  exactly like the upstream demo), Generate3D via t2_generate +
  t2_bake_glb writing a binary glTF to dst. Weight-free unit tests
  cover resolution/validation/param mapping — CI never downloads the
  multi-GB GGUF set or runs inference.
- CPU SIMD variants build into per-variant directories (the shared
  libggml sonames collide across variants, unlike sd-ggml's flat
  renamed-.so scheme); run.sh picks one via /proc/cpuinfo.
- CI wiring: backend-matrix entries (cpu, cuda12/13, vulkan
  amd64+arm64, l4t, l4t-cuda13, darwin metal), index.yaml meta +
  latest/master image entries, bump_deps tracking of the pbr-textures
  branch, changed-backends.js mapping, top-level Makefile targets.
- Importer: auto-detects trellis GGUF repos/URIs (registered before
  llama-cpp so the .gguf match isn't stolen) and expands any trellis
  URI to the full 10-file component set spanning the three LocalAI-io
  HF repos.
- Gallery: trellis2-4b (full PBR + 1024 cascade) and
  trellis2-4b-geometry (512 untextured) with verified sha256s.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui): 3D generation page with native GLB viewer and IndexedDB history

Adds a Studio tab + /app/3d page for the new image-to-3D endpoint:

- GlbViewer ports the trellis2cpp demo's dependency-free WebGL2
  renderer (quaternion trackball, metallic-roughness PBR, ACES,
  hidden-line wireframe with a bounded index budget) and pairs it with
  a minimal GLB parser for the two forms t2_bake_glb emits — dense
  vertex-PBR (linear COLOR_0 + _METALLIC_ROUGHNESS, uploaded as
  normalized integers) and the opt-in UV-atlas textured form. Parsing
  happens before any GL so stats and errors render without WebGL2.
- use3DHistory stores past generations (params, input thumbnail, and
  the GLB blob itself) in IndexedDB with keep-newest-20 eviction —
  GLBs are multi-MB binaries localStorage can't hold — and the page
  offers a download button for the active GLB.
- Wiring: CAP_3D capability constant (FLAG_3D — the exact string
  /api/models/capabilities serves), threeDApi, router entries, Studio
  tab, vite dev proxy, en locale keys.
- e2e: render-smoke entry plus a focused spec that feeds a real
  one-triangle vertex-PBR GLB through the parser/viewer and exercises
  IndexedDB persistence, selection, deletion, and API errors.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(3d): address API correctness and UX issues

Keep 3D generation on the LocalAI-specific /3d/generations route and ensure authentication and permissions cover it.

Propagate distributed transfer failures, publish a portable ARM64 backend image, honor importer overrides, and align discovery, upload validation, and touch controls.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(3d): add previewable print remeshing

Add a single-detail CGAL Alpha Wrap workflow for existing Trellis GLBs, including PBR reprojection, API documentation, tracing, and an in-browser preview before download.

Allow the remesh route to enforce its 512 MiB upload cap independently of the smaller global default so generated high-resolution meshes can be processed.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* build(trellis2cpp): centralize remesh dependency pins

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(kokoros): implement Generate3D stub for new proto RPC

The Generate3D RPC added to backend.proto for the trellis2cpp backend
made tonic's generated Backend trait require generate3_d, breaking the
kokoros-grpc build. Return unimplemented like the other unsupported
modalities.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
This commit is contained in:
Richard Palethorpe
2026-07-29 15:15:04 +01:00
committed by GitHub
parent 8089b2bf09
commit 9058a2bb46
83 changed files with 5231 additions and 19 deletions

View File

@@ -756,6 +756,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-12-trellis2cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "8"
@@ -1716,6 +1729,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-nvidia-cuda-13-trellis2cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -1729,6 +1755,19 @@ include:
backend: "stablediffusion-ggml"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-cuda-13-arm64-trellis2cpp'
base-image: "ubuntu:24.04"
ubuntu-version: '2404'
runs-on: 'ubuntu-24.04-arm'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
- build-type: 'cublas'
cuda-major-version: "13"
cuda-minor-version: "0"
@@ -3267,6 +3306,35 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# trellis2cpp
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-cpu-trellis2cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: ''
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-cpu-trellis2cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
# sam3-cpp
- build-type: ''
cuda-major-version: ""
@@ -3592,6 +3660,34 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/amd64'
platform-tag: 'amd64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-trellis2cpp'
runs-on: 'ubuntu-latest'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'vulkan'
cuda-major-version: ""
cuda-minor-version: ""
platforms: 'linux/arm64'
platform-tag: 'arm64'
tag-latest: 'auto'
tag-suffix: '-gpu-vulkan-trellis2cpp'
runs-on: 'ubuntu-24.04-arm'
base-image: "ubuntu:24.04"
skip-drivers: 'false'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2404'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -3605,6 +3701,19 @@ include:
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
platforms: 'linux/arm64'
skip-drivers: 'false'
tag-latest: 'auto'
tag-suffix: '-nvidia-l4t-arm64-trellis2cpp'
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
runs-on: 'ubuntu-24.04-arm'
backend: "trellis2cpp"
dockerfile: "./backend/Dockerfile.golang"
context: "./"
ubuntu-version: '2204'
- build-type: 'cublas'
cuda-major-version: "12"
cuda-minor-version: "0"
@@ -5977,6 +6086,10 @@ includeDarwin:
tag-suffix: "-metal-darwin-arm64-stablediffusion-ggml"
build-type: "metal"
lang: "go"
- backend: "trellis2cpp"
tag-suffix: "-metal-darwin-arm64-trellis2cpp"
build-type: "metal"
lang: "go"
- backend: "whisper"
tag-suffix: "-metal-darwin-arm64-whisper"
build-type: "metal"

View File

@@ -78,6 +78,10 @@ jobs:
variable: "STABLEDIFFUSION_GGML_VERSION"
branch: "master"
file: "backend/go/stablediffusion-ggml/Makefile"
- repository: "localai-org/trellis2cpp"
variable: "TRELLIS2CPP_VERSION"
branch: "pbr-textures"
file: "backend/go/trellis2cpp/Makefile"
- repository: "mudler/go-piper"
variable: "PIPER_VERSION"
branch: "master"

View File

@@ -38,6 +38,7 @@ jobs:
acestep-cpp: ${{ steps.detect.outputs.acestep-cpp }}
qwen3-tts-cpp: ${{ steps.detect.outputs.qwen3-tts-cpp }}
magpie-tts-cpp: ${{ steps.detect.outputs.magpie-tts-cpp }}
trellis2cpp: ${{ steps.detect.outputs.trellis2cpp }}
rfdetr-cpp: ${{ steps.detect.outputs.rfdetr-cpp }}
locate-anything-cpp: ${{ steps.detect.outputs.locate-anything-cpp }}
vibevoice-cpp: ${{ steps.detect.outputs.vibevoice-cpp }}
@@ -935,6 +936,41 @@ jobs:
- name: Test rfdetr-cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/rfdetr-cpp test
# Weight-free packaged-backend smoke for trellis2cpp. Starting run.sh loads
# libtrellis2 + ggml, resolves the complete C ABI (including remeshing), and
# answers gRPC Health without downloading or loading the multi-GB model set.
tests-trellis2cpp:
needs: detect-changes
if: needs.detect-changes.outputs.trellis2cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Clone
uses: actions/checkout@v7
with:
submodules: true
- name: Dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake curl unzip
- name: Setup Go
uses: actions/setup-go@v5
- name: Display Go version
run: go version
- name: Proto Dependencies
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \
unzip -j -d /usr/local/bin protoc.zip bin/protoc && \
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Build trellis2cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/trellis2cpp
- name: Test trellis2cpp
run: |
make --jobs=5 --output-sync=target -C backend/go/trellis2cpp test
# Per-backend e2e for locate-anything-cpp: builds the .so + Go binary and
# runs `make -C backend/go/locate-anything-cpp test`. test.sh fetches the
# locate-anything-q8_0 GGUF (~6.3 GB, NVIDIA LocateAnything-3B) from the

View File

@@ -1,5 +1,5 @@
# Disable parallel execution for backend builds
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/turboquant backends/bonsai backends/outetts backends/piper backends/stablediffusion-ggml backends/trellis2cpp backends/trellis2cpp-darwin backends/whisper backends/crispasr backends/parakeet-cpp backends/moss-transcribe-cpp backends/faster-whisper backends/silero-vad backends/local-store backends/cloud-proxy backends/huggingface backends/rfdetr backends/rfdetr-cpp backends/insightface backends/speaker-recognition backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/mlx-distributed backends/stablediffusion-ggml-darwin backends/vllm backends/vllm-omni backends/longcat-video backends/sglang backends/moonshine backends/pocket-tts backends/qwen-tts backends/faster-qwen3-tts backends/qwen-asr backends/nemo backends/voxcpm backends/whisperx backends/ace-step backends/acestep-cpp backends/fish-speech backends/voxtral backends/opus backends/trl backends/llama-cpp-quantization backends/kokoros backends/sam3-cpp backends/qwen3-tts-cpp backends/moss-tts-cpp backends/magpie-tts-cpp backends/vllm-cpp backends/omnivoice-cpp backends/vibevoice-cpp backends/localvqe backends/tinygrad backends/sherpa-onnx backends/ds4 backends/ds4-darwin backends/liquid-audio backends/supertonic backends/depth-anything-cpp backends/privacy-filter backends/privacy-filter-darwin
GOCMD=go
GOTEST=$(GOCMD) test
@@ -594,6 +594,7 @@ prepare-test-extra: protogen-python
$(MAKE) -C backend/rust/kokoros kokoros-grpc
$(MAKE) -C backend/go/rfdetr-cpp
$(MAKE) -C backend/go/locate-anything-cpp
$(MAKE) -C backend/go/trellis2cpp
test-extra: prepare-test-extra
$(MAKE) -C backend/python/transformers test
@@ -626,6 +627,7 @@ test-extra: prepare-test-extra
$(MAKE) -C backend/go/depth-anything-cpp test
$(MAKE) -C backend/go/supertonic test
$(MAKE) -C backend/go/vllm-cpp test
$(MAKE) -C backend/go/trellis2cpp test
##
## End-to-end gRPC tests that exercise a built backend container image.
@@ -1218,6 +1220,10 @@ backends/stablediffusion-ggml-darwin:
BACKEND=stablediffusion-ggml BUILD_TYPE=metal $(MAKE) build-darwin-go-backend
./local-ai backends install "ocifile://$(abspath ./backend-images/stablediffusion-ggml.tar)"
backends/trellis2cpp-darwin:
BACKEND=trellis2cpp BUILD_TYPE=metal $(MAKE) build-darwin-go-backend
./local-ai backends install "ocifile://$(abspath ./backend-images/trellis2cpp.tar)"
backend-images:
mkdir -p backend-images
@@ -1249,6 +1255,7 @@ BACKEND_CLOUD_PROXY = cloud-proxy|golang|.|false|true
BACKEND_HUGGINGFACE = huggingface|golang|.|false|true
BACKEND_SILERO_VAD = silero-vad|golang|.|false|true
BACKEND_STABLEDIFFUSION_GGML = stablediffusion-ggml|golang|.|--progress=plain|true
BACKEND_TRELLIS2CPP = trellis2cpp|golang|.|--progress=plain|true
BACKEND_WHISPER = whisper|golang|.|false|true
BACKEND_CRISPASR = crispasr|golang|.|false|true
BACKEND_PARAKEET_CPP = parakeet-cpp|golang|.|false|true
@@ -1348,6 +1355,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_CLOUD_PROXY)))
$(eval $(call generate-docker-build-target,$(BACKEND_HUGGINGFACE)))
$(eval $(call generate-docker-build-target,$(BACKEND_SILERO_VAD)))
$(eval $(call generate-docker-build-target,$(BACKEND_STABLEDIFFUSION_GGML)))
$(eval $(call generate-docker-build-target,$(BACKEND_TRELLIS2CPP)))
$(eval $(call generate-docker-build-target,$(BACKEND_WHISPER)))
$(eval $(call generate-docker-build-target,$(BACKEND_CRISPASR)))
$(eval $(call generate-docker-build-target,$(BACKEND_PARAKEET_CPP)))
@@ -1408,7 +1416,7 @@ $(eval $(call generate-docker-build-target,$(BACKEND_SUPERTONIC)))
docker-save-%: backend-images
docker save local-ai-backend:$* -o backend-images/$*.tar
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter
docker-build-backends: docker-build-llama-cpp docker-build-ik-llama-cpp docker-build-turboquant docker-build-bonsai docker-build-ds4 docker-build-rerankers docker-build-vllm docker-build-vllm-omni docker-build-longcat-video docker-build-sglang docker-build-transformers docker-build-outetts docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-crispasr docker-build-coqui docker-build-chatterbox docker-build-vibevoice docker-build-liquid-audio docker-build-moonshine docker-build-pocket-tts docker-build-qwen-tts docker-build-fish-speech docker-build-faster-qwen3-tts docker-build-qwen-asr docker-build-nemo docker-build-voxcpm docker-build-whisperx docker-build-ace-step docker-build-acestep-cpp docker-build-voxtral docker-build-mlx-distributed docker-build-trl docker-build-llama-cpp-quantization docker-build-tinygrad docker-build-kokoros docker-build-sam3-cpp docker-build-rfdetr-cpp docker-build-qwen3-tts-cpp docker-build-moss-tts-cpp docker-build-magpie-tts-cpp docker-build-vllm-cpp docker-build-omnivoice-cpp docker-build-vibevoice-cpp docker-build-localvqe docker-build-insightface docker-build-speaker-recognition docker-build-sherpa-onnx docker-build-cloud-proxy docker-build-supertonic docker-build-depth-anything-cpp docker-build-moss-transcribe-cpp docker-build-privacy-filter docker-build-trellis2cpp
########################################################
### Mock Backend for E2E Tests

View File

@@ -245,6 +245,7 @@ Most backends wrap a best-in-class upstream engine. A handful of them are native
| [depth-anything.cpp](https://github.com/mudler/depth-anything.cpp) | Depth Anything 3 monocular metric depth + camera pose estimation |
| [face-detect.cpp](https://github.com/mudler/face-detect.cpp) | Face detection, recognition, demographics and anti-spoofing (SCRFD/ArcFace, YuNet/SFace), replacing the Python insightface backend |
| [free-splatter.cpp](https://github.com/localai-org/free-splatter.cpp) | Pose-free 3D reconstruction (FreeSplatter): turns a handful of plain photos into 3D Gaussians, no camera poses or GPU required |
| [trellis2.cpp](https://github.com/localai-org/trellis2cpp) | C++/GGML port of Microsoft TRELLIS.2: single-image to textured 3D mesh (GLB with PBR materials) |
| [privacy-filter.cpp](https://github.com/localai-org/privacy-filter.cpp) | Standalone GGML PII/NER token-classification engine powering LocalAI's PII redaction tier |
| [LocalVQE](https://github.com/localai-org/LocalVQE) | Joint acoustic echo cancellation, noise suppression, and dereverberation |
| [local-store](https://github.com/mudler/LocalAI) | Local-first vector database for embeddings (shipped in-tree) |

View File

@@ -16,6 +16,7 @@ service Backend {
rpc Embedding(PredictOptions) returns (EmbeddingResult) {}
rpc GenerateImage(GenerateImageRequest) returns (Result) {}
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
rpc Generate3D(Generate3DRequest) returns (Result) {}
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
rpc AudioTranscriptionStream(TranscriptRequest) returns (stream TranscriptStreamResponse) {}
// AudioTranscriptionLive is the bidirectional live-microphone ASR RPC. The
@@ -658,6 +659,20 @@ message GenerateVideoRequest {
string ModelIdentity = 15;
}
message Generate3DRequest {
string src = 1; // Path to the staged conditioning image (3D generation is image-conditioned)
string dst = 2; // Output path for the generated binary glTF (.glb) asset
int32 seed = 3; // <=0 lets the backend pick a random seed
int32 step = 4; // Flow sampling steps; <=0 uses the backend default
float cfg_scale = 5; // Classifier-free guidance scale; <=0 uses the backend default
int32 texture_steps = 6; // Texture flow sampling steps; <=0 uses the backend default
string quality = 7; // Mesh pipeline: ""|"auto"|"coarse"|"512"|"1024"
string background = 8; // Conditioning-image background handling: ""|"auto"|"keep"|"black"|"white"
// Backend-specific per-request generation parameters. Values are strings
// and are validated/coerced by the selected backend.
map<string, string> params = 9;
}
message TTSRequest {
string text = 1;
string model = 2;

6
backend/go/trellis2cpp/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
package/
sources/
.cache/
build-*/
variants/
trellis2cpp

View File

@@ -0,0 +1,132 @@
CMAKE_ARGS?=
BUILD_TYPE?=
NATIVE?=false
CURRENT_DIR=$(abspath ./)
GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# trellis2.cpp — C++/ggml port of Microsoft TRELLIS.2 (image -> 3D GLB).
# The ggml submodule is pinned by trellis2cpp's .gitmodules and fetched via
# --recursive. The commit pin lives here so bump_deps.yaml can update it.
TRELLIS2CPP_REPO?=https://github.com/localai-org/trellis2cpp
TRELLIS2CPP_VERSION?=73dfbe5dfc2cbefd0950853718086556c6d9b043
# libtrellis2 + ggml as shared libraries; no example/test binaries.
CMAKE_ARGS+=-DCMAKE_BUILD_TYPE=Release
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=ON
CMAKE_ARGS+=-DTRELLIS2_BUILD_EXAMPLES=OFF
CMAKE_ARGS+=-DTRELLIS2_BUILD_TESTS=OFF
# Print remeshing is part of trellis2cpp's ABI, so upstream owns the tested
# CGAL/Boost versions, checksums, fetch logic, and update automation. LocalAI
# only opts into that dependency set and pins the trellis2cpp commit above.
CMAKE_ARGS+=-DTRELLIS2_FETCH_PRINT_REMESH_DEPS=ON
CMAKE_ARGS+=-DTRELLIS2_PRINT_REMESH_DEPS_DIR=$(CURRENT_DIR)/sources/print-remesh-deps
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF
endif
ifeq ($(BUILD_TYPE),cublas)
CMAKE_ARGS+=-DGGML_CUDA=ON
else ifeq ($(BUILD_TYPE),vulkan)
CMAKE_ARGS+=-DGGML_VULKAN=ON
else ifeq ($(BUILD_TYPE),hipblas)
ROCM_HOME ?= /opt/rocm
ROCM_PATH ?= /opt/rocm
export CXX=$(ROCM_HOME)/llvm/bin/clang++
export CC=$(ROCM_HOME)/llvm/bin/clang
AMDGPU_TARGETS?=gfx908,gfx90a,gfx942,gfx950,gfx1030,gfx1100,gfx1101,gfx1102,gfx1200,gfx1201
CMAKE_ARGS+=-DGGML_HIP=ON -DAMDGPU_TARGETS=$(AMDGPU_TARGETS)
else ifeq ($(OS),Darwin)
ifneq ($(BUILD_TYPE),metal)
CMAKE_ARGS+=-DTRELLIS2_METAL=OFF -DGGML_METAL=OFF
else
# trellis2cpp turns on GGML_METAL(+EMBED_LIBRARY) itself when
# TRELLIS2_METAL is enabled on Apple platforms.
CMAKE_ARGS+=-DTRELLIS2_METAL=ON
endif
# Dependent libggml*.dylib resolve next to libtrellis2.dylib even
# without DYLD_LIBRARY_PATH being exported.
CMAKE_ARGS+=-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DCMAKE_INSTALL_RPATH=@loader_path
endif
ifeq ($(BUILD_TYPE),sycl_f16)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx \
-DGGML_SYCL_F16=ON
endif
ifeq ($(BUILD_TYPE),sycl_f32)
CMAKE_ARGS+=-DGGML_SYCL=ON \
-DCMAKE_C_COMPILER=icx \
-DCMAKE_CXX_COMPILER=icpx
endif
sources/trellis2cpp:
git clone --recursive $(TRELLIS2CPP_REPO) sources/trellis2cpp && \
cd sources/trellis2cpp && \
git checkout $(TRELLIS2CPP_VERSION) && \
git submodule update --init --recursive --depth 1 --single-branch
# Detect OS
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
# The AVX variants are x86-only. ARM64 images use the portable fallback while
# still enabling the selected GPU backend (Vulkan/CUDA) through CMAKE_ARGS.
ifeq ($(UNAME_S),Linux)
ifneq (,$(filter x86_64 amd64,$(UNAME_M)))
VARIANTS = avx avx2 avx512 fallback
else
VARIANTS = fallback
endif
else
# On non-Linux (e.g., Darwin), build only the fallback variant
VARIANTS = fallback
endif
VARIANT_TARGETS = $(foreach v,$(VARIANTS),variants/$(v)/.built)
VARIANT_FLAGS_avx = -DGGML_AVX=on -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off
VARIANT_FLAGS_avx2 = -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=off -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on
VARIANT_FLAGS_avx512 = -DGGML_AVX=on -DGGML_AVX2=on -DGGML_AVX512=on -DGGML_FMA=on -DGGML_F16C=on -DGGML_BMI2=on
VARIANT_FLAGS_fallback = -DGGML_AVX=off -DGGML_AVX2=off -DGGML_AVX512=off -DGGML_FMA=off -DGGML_F16C=off -DGGML_BMI2=off
# libtrellis2 links libggml/libggml-base/libggml-cpu (+ the GPU backend) by
# soname, and those sonames collide across SIMD variants — so each variant
# lives in its own directory and run.sh selects one via LD_LIBRARY_PATH,
# unlike stablediffusion-ggml's flat renamed-.so scheme.
variants/%/.built: sources/trellis2cpp
rm -rf build-$* variants/$*
mkdir -p build-$* variants/$*
cd build-$* && cmake ../sources/trellis2cpp $(CMAKE_ARGS) $(VARIANT_FLAGS_$*) && \
cmake --build . --config Release -j$(JOBS)
@for f in build-$*/libtrellis2.so build-$*/libtrellis2.dylib; do \
if [ -e $$f ]; then cp -a $$f variants/$*/; fi; done
find build-$*/ggml \( -name 'libggml*.so*' -o -name 'libggml*.dylib' \) -exec cp -a {} variants/$*/ \;
rm -rf build-$*
touch $@
trellis2cpp: main.go trellis2.go $(VARIANT_TARGETS)
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o trellis2cpp ./
package: trellis2cpp
bash package.sh
build: package
clean: purge
rm -rf variants trellis2cpp package sources
purge:
rm -rf build-*
# Weight-free by construction: pure-Go unit tests over model-path resolution,
# validation, and request-parameter mapping. The multi-GB GGUF weights are
# never downloaded in CI; end-to-end generation is exercised manually.
test:
$(GOCMD) test -v ./...
all: trellis2cpp package

View File

@@ -0,0 +1,252 @@
package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"math"
)
const (
glbMagic = 0x46546c67
glbJSONChunk = 0x4e4f534a
glbBINChunk = 0x004e4942
)
type glbAccessor struct {
BufferView int `json:"bufferView"`
ByteOffset int `json:"byteOffset"`
ComponentType int `json:"componentType"`
Count int `json:"count"`
Type string `json:"type"`
Normalized bool `json:"normalized"`
}
type glbBufferView struct {
Buffer int `json:"buffer"`
ByteOffset int `json:"byteOffset"`
ByteLength int `json:"byteLength"`
ByteStride int `json:"byteStride"`
}
type glbPrimitive struct {
Attributes map[string]int `json:"attributes"`
Indices *int `json:"indices"`
}
type glbDocument struct {
Accessors []glbAccessor `json:"accessors"`
BufferViews []glbBufferView `json:"bufferViews"`
Meshes []struct {
Primitives []glbPrimitive `json:"primitives"`
} `json:"meshes"`
}
type glbVertexMesh struct {
verts []float32
tris []int32
pbr []float32
}
func glbLayout(componentType int, accessorType string) (componentBytes, components int, err error) {
switch componentType {
case 5121:
componentBytes = 1
case 5123:
componentBytes = 2
case 5125, 5126:
componentBytes = 4
default:
return 0, 0, fmt.Errorf("unsupported GLB component type %d", componentType)
}
switch accessorType {
case "SCALAR":
components = 1
case "VEC2":
components = 2
case "VEC3":
components = 3
case "VEC4":
components = 4
default:
return 0, 0, fmt.Errorf("unsupported GLB accessor type %q", accessorType)
}
return componentBytes, components, nil
}
func glbAccessorData(doc *glbDocument, binChunk []byte, index int) (glbAccessor, []byte, error) {
if index < 0 || index >= len(doc.Accessors) {
return glbAccessor{}, nil, fmt.Errorf("missing GLB accessor %d", index)
}
a := doc.Accessors[index]
if a.BufferView < 0 || a.BufferView >= len(doc.BufferViews) {
return glbAccessor{}, nil, fmt.Errorf("missing GLB buffer view %d", a.BufferView)
}
v := doc.BufferViews[a.BufferView]
if v.Buffer != 0 || v.ByteStride != 0 {
return glbAccessor{}, nil, fmt.Errorf("interleaved or external GLB buffers are unsupported")
}
componentBytes, components, err := glbLayout(a.ComponentType, a.Type)
if err != nil {
return glbAccessor{}, nil, err
}
if a.Count <= 0 || a.Count > math.MaxInt/(componentBytes*components) {
return glbAccessor{}, nil, fmt.Errorf("invalid GLB accessor count %d", a.Count)
}
length := a.Count * componentBytes * components
if v.ByteOffset < 0 || v.ByteLength < 0 || a.ByteOffset < 0 ||
a.ByteOffset > v.ByteLength || length > v.ByteLength-a.ByteOffset ||
length > len(binChunk) || v.ByteOffset > len(binChunk)-length-a.ByteOffset {
return glbAccessor{}, nil, fmt.Errorf("GLB accessor %d is outside the BIN chunk", index)
}
start := v.ByteOffset + a.ByteOffset
return a, binChunk[start : start+length], nil
}
// parseVertexGLB reads the dense vertex-PBR form emitted by trellis2.cpp. GLB
// coordinates and linear COLOR_0 values are converted back to the native
// trellis coordinate/material convention before CGAL remeshing and rebaking.
func parseVertexGLB(data []byte) (*glbVertexMesh, error) {
if len(data) < 20 || binary.LittleEndian.Uint32(data[0:4]) != glbMagic {
return nil, fmt.Errorf("input is not a GLB file")
}
if binary.LittleEndian.Uint32(data[4:8]) != 2 {
return nil, fmt.Errorf("unsupported GLB version")
}
total := int(binary.LittleEndian.Uint32(data[8:12]))
if total != len(data) {
return nil, fmt.Errorf("invalid GLB length")
}
var jsonChunk, binChunk []byte
for offset := 12; offset <= len(data)-8; {
length := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
chunkType := binary.LittleEndian.Uint32(data[offset+4 : offset+8])
start := offset + 8
if length < 0 || start > len(data)-length {
return nil, fmt.Errorf("invalid GLB chunk length")
}
switch chunkType {
case glbJSONChunk:
if jsonChunk == nil {
jsonChunk = data[start : start+length]
}
case glbBINChunk:
if binChunk == nil {
binChunk = data[start : start+length]
}
}
offset = start + length
}
if jsonChunk == nil || binChunk == nil {
return nil, fmt.Errorf("GLB must contain JSON and BIN chunks")
}
var doc glbDocument
if err := json.Unmarshal(jsonChunk, &doc); err != nil {
return nil, fmt.Errorf("parsing GLB JSON: %w", err)
}
if len(doc.Meshes) != 1 || len(doc.Meshes[0].Primitives) != 1 {
return nil, fmt.Errorf("GLB must contain one mesh primitive")
}
primitive := doc.Meshes[0].Primitives[0]
positionIndex, ok := primitive.Attributes["POSITION"]
if !ok {
return nil, fmt.Errorf("GLB mesh has no POSITION attribute")
}
position, positionData, err := glbAccessorData(&doc, binChunk, positionIndex)
if err != nil {
return nil, err
}
if position.ComponentType != 5126 || position.Type != "VEC3" {
return nil, fmt.Errorf("GLB POSITION must be float32 VEC3")
}
mesh := &glbVertexMesh{verts: make([]float32, position.Count*3)}
for i := 0; i < position.Count; i++ {
x := math.Float32frombits(binary.LittleEndian.Uint32(positionData[(i*3)*4:]))
y := math.Float32frombits(binary.LittleEndian.Uint32(positionData[(i*3+1)*4:]))
z := math.Float32frombits(binary.LittleEndian.Uint32(positionData[(i*3+2)*4:]))
if math.IsNaN(float64(x)) || math.IsNaN(float64(y)) || math.IsNaN(float64(z)) ||
math.IsInf(float64(x), 0) || math.IsInf(float64(y), 0) || math.IsInf(float64(z), 0) {
return nil, fmt.Errorf("GLB POSITION contains a non-finite value")
}
mesh.verts[i*3] = x
mesh.verts[i*3+1] = -z
mesh.verts[i*3+2] = y
}
if primitive.Indices == nil {
if position.Count%3 != 0 {
return nil, fmt.Errorf("unindexed GLB vertex count is not divisible by three")
}
mesh.tris = make([]int32, position.Count)
for i := range mesh.tris {
mesh.tris[i] = int32(i)
}
} else {
indices, indexData, err := glbAccessorData(&doc, binChunk, *primitive.Indices)
if err != nil {
return nil, err
}
if indices.Type != "SCALAR" || indices.Count%3 != 0 || (indices.ComponentType != 5123 && indices.ComponentType != 5125) {
return nil, fmt.Errorf("GLB indices must be uint16/uint32 triangles")
}
mesh.tris = make([]int32, indices.Count)
for i := range mesh.tris {
var value uint32
if indices.ComponentType == 5123 {
value = uint32(binary.LittleEndian.Uint16(indexData[i*2:]))
} else {
value = binary.LittleEndian.Uint32(indexData[i*4:])
}
if value >= uint32(position.Count) || value > math.MaxInt32 {
return nil, fmt.Errorf("GLB index %d is outside the vertex buffer", value)
}
mesh.tris[i] = int32(value)
}
}
colorIndex, hasColor := primitive.Attributes["COLOR_0"]
if !hasColor {
return mesh, nil
}
color, colorData, err := glbAccessorData(&doc, binChunk, colorIndex)
if err != nil {
return nil, err
}
if color.ComponentType != 5123 || color.Type != "VEC4" || !color.Normalized || color.Count != position.Count {
return nil, fmt.Errorf("GLB COLOR_0 must be normalized uint16 VEC4 aligned with POSITION")
}
metalRoughIndex, hasMetalRough := primitive.Attributes["_METALLIC_ROUGHNESS"]
var metalRoughData []byte
if hasMetalRough {
metalRough, data, err := glbAccessorData(&doc, binChunk, metalRoughIndex)
if err != nil {
return nil, err
}
if metalRough.ComponentType != 5121 || metalRough.Type != "VEC2" || !metalRough.Normalized || metalRough.Count != position.Count {
return nil, fmt.Errorf("GLB _METALLIC_ROUGHNESS must be normalized uint8 VEC2 aligned with POSITION")
}
metalRoughData = data
}
mesh.pbr = make([]float32, position.Count*6)
for i := 0; i < position.Count; i++ {
for channel := 0; channel < 3; channel++ {
linear := float32(binary.LittleEndian.Uint16(colorData[(i*4+channel)*2:])) / 65535
if linear <= 0.0031308 {
mesh.pbr[i*6+channel] = linear * 12.92
} else {
mesh.pbr[i*6+channel] = 1.055*float32(math.Pow(float64(linear), 1.0/2.4)) - 0.055
}
}
mesh.pbr[i*6+5] = float32(binary.LittleEndian.Uint16(colorData[(i*4+3)*2:])) / 65535
mesh.pbr[i*6+4] = 0.6
if hasMetalRough {
mesh.pbr[i*6+3] = float32(metalRoughData[i*2]) / 255
mesh.pbr[i*6+4] = float32(metalRoughData[i*2+1]) / 255
}
}
return mesh, nil
}

View File

@@ -0,0 +1,74 @@
package main
import (
"encoding/binary"
"fmt"
"math"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func tinyVertexGLB() []byte {
bin := make([]byte, 80)
positions := []float32{1, 2, 3, 4, 5, 6, 7, 8, 9}
for i, value := range positions {
binary.LittleEndian.PutUint32(bin[i*4:], math.Float32bits(value))
}
colors := []uint16{
65535, 0, 0, 65535,
0, 65535, 0, 32768,
0, 0, 65535, 65535,
}
for i, value := range colors {
binary.LittleEndian.PutUint16(bin[36+i*2:], value)
}
copy(bin[60:], []byte{0, 153, 64, 128, 255, 32})
for i, value := range []uint32{0, 1, 2} {
binary.LittleEndian.PutUint32(bin[68+i*4:], value)
}
jsonChunk := []byte(fmt.Sprintf(`{"asset":{"version":"2.0"},"meshes":[{"primitives":[{"attributes":{"POSITION":0,"COLOR_0":1,"_METALLIC_ROUGHNESS":2},"indices":3}]}],"accessors":[{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"},{"bufferView":1,"componentType":5123,"normalized":true,"count":3,"type":"VEC4"},{"bufferView":2,"componentType":5121,"normalized":true,"count":3,"type":"VEC2"},{"bufferView":3,"componentType":5125,"count":3,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":36},{"buffer":0,"byteOffset":36,"byteLength":24},{"buffer":0,"byteOffset":60,"byteLength":6},{"buffer":0,"byteOffset":68,"byteLength":12}],"buffers":[{"byteLength":%d}]}`, len(bin)))
for len(jsonChunk)%4 != 0 {
jsonChunk = append(jsonChunk, ' ')
}
total := 12 + 8 + len(jsonChunk) + 8 + len(bin)
glb := make([]byte, total)
binary.LittleEndian.PutUint32(glb[0:], glbMagic)
binary.LittleEndian.PutUint32(glb[4:], 2)
binary.LittleEndian.PutUint32(glb[8:], uint32(total))
binary.LittleEndian.PutUint32(glb[12:], uint32(len(jsonChunk)))
binary.LittleEndian.PutUint32(glb[16:], glbJSONChunk)
copy(glb[20:], jsonChunk)
binHeader := 20 + len(jsonChunk)
binary.LittleEndian.PutUint32(glb[binHeader:], uint32(len(bin)))
binary.LittleEndian.PutUint32(glb[binHeader+4:], glbBINChunk)
copy(glb[binHeader+8:], bin)
return glb
}
var _ = Describe("vertex GLB parsing for print remeshing", func() {
It("restores trellis coordinates, topology, and PBR values", func() {
mesh, err := parseVertexGLB(tinyVertexGLB())
Expect(err).NotTo(HaveOccurred())
Expect(mesh.verts).To(Equal([]float32{1, -3, 2, 4, -6, 5, 7, -9, 8}))
Expect(mesh.tris).To(Equal([]int32{0, 1, 2}))
Expect(mesh.pbr).To(HaveLen(18))
Expect(mesh.pbr[0]).To(BeNumerically("~", 1, 1e-5))
Expect(mesh.pbr[3]).To(BeNumerically("~", 0, 1e-5))
Expect(mesh.pbr[4]).To(BeNumerically("~", 0.6, 0.01))
Expect(mesh.pbr[11]).To(BeNumerically("~", 32768.0/65535.0, 1e-5))
})
It("rejects indices outside the source vertex buffer", func() {
glb := tinyVertexGLB()
binary.LittleEndian.PutUint32(glb[len(glb)-12:], 3)
_, err := parseVertexGLB(glb)
Expect(err).To(MatchError(ContainSubstring("outside the vertex buffer")))
})
It("rejects non-GLB input", func() {
_, err := parseVertexGLB([]byte("not a mesh"))
Expect(err).To(MatchError("input is not a GLB file"))
})
})

View File

@@ -0,0 +1,50 @@
package main
import (
"flag"
"fmt"
"os"
"runtime"
"github.com/ebitengine/purego"
grpc "github.com/mudler/LocalAI/pkg/grpc"
)
var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
)
func registerLibFuncs(lib uintptr) {
registerLibFuncsWith(func(fptr any, name string) {
purego.RegisterLibFunc(fptr, lib, name)
})
}
func main() {
// run.sh selects the CPU-variant directory and points TRELLIS2_LIBRARY at it.
libName := os.Getenv("TRELLIS2_LIBRARY")
if libName == "" {
if runtime.GOOS == "darwin" {
libName = "./variants/fallback/libtrellis2.dylib"
} else {
libName = "./variants/fallback/libtrellis2.so"
}
}
lib, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
panic(err)
}
registerLibFuncs(lib)
if got := t2AbiVersion(); got != abiVersion {
panic(fmt.Sprintf("trellis2 ABI mismatch: library reports %d, backend built for %d", got, abiVersion))
}
flag.Parse()
if err := grpc.StartServer(*addr, &Trellis2{}); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
# Each CPU variant keeps its libtrellis2 + libggml* set in its own directory
# (their sonames collide across variants); run.sh selects one at startup.
cp -a $CURDIR/variants $CURDIR/package/
cp -avf $CURDIR/trellis2cpp $CURDIR/package/
cp -fv $CURDIR/run.sh $CURDIR/package/
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
# ARM64 architecture
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ $(uname -s) = "Darwin" ]; then
echo "Detected Darwin"
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
# The GPU library packaging script will detect BUILD_TYPE and copy appropriate GPU libraries
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah $CURDIR/package/
ls -liah $CURDIR/package/lib/

61
backend/go/trellis2cpp/run.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/bin/bash
set -ex
# Get the absolute current dir where the script is located
CURDIR=$(dirname "$(realpath "$0")")
cd /
echo "CPU info:"
if [ "$(uname)" != "Darwin" ]; then
grep -e "model\sname" /proc/cpuinfo | head -1
grep -e "flags" /proc/cpuinfo | head -1
fi
# Each variant directory bundles libtrellis2 plus its libggml* set (the ggml
# sonames collide across SIMD variants, so they can't share one directory).
VARIANT=fallback
if [ "$(uname)" = "Darwin" ]; then
LIBRARY="$CURDIR/variants/$VARIANT/libtrellis2.dylib"
if [ ! -e "$LIBRARY" ]; then
LIBRARY="$CURDIR/variants/$VARIANT/libtrellis2.so"
fi
export DYLD_LIBRARY_PATH="$CURDIR/variants/$VARIANT:$CURDIR/lib:$DYLD_LIBRARY_PATH"
else
if grep -q -e "\savx\s" /proc/cpuinfo ; then
echo "CPU: AVX found OK"
if [ -d "$CURDIR/variants/avx" ]; then
VARIANT=avx
fi
fi
if grep -q -e "\savx2\s" /proc/cpuinfo ; then
echo "CPU: AVX2 found OK"
if [ -d "$CURDIR/variants/avx2" ]; then
VARIANT=avx2
fi
fi
if grep -q -e "\savx512f\s" /proc/cpuinfo ; then
echo "CPU: AVX512F found OK"
if [ -d "$CURDIR/variants/avx512" ]; then
VARIANT=avx512
fi
fi
LIBRARY="$CURDIR/variants/$VARIANT/libtrellis2.so"
export LD_LIBRARY_PATH="$CURDIR/variants/$VARIANT:$CURDIR/lib:$LD_LIBRARY_PATH"
fi
export TRELLIS2_LIBRARY=$LIBRARY
# If there is a lib/ld.so, use it
if [ -f "$CURDIR"/lib/ld.so ]; then
echo "Using lib/ld.so"
echo "Using library: $LIBRARY"
exec "$CURDIR"/lib/ld.so "$CURDIR"/trellis2cpp "$@"
fi
echo "Using library: $LIBRARY"
exec "$CURDIR"/trellis2cpp "$@"

View File

@@ -0,0 +1,511 @@
package main
// trellis2.go — purego bindings to libtrellis2's flat C ABI (trellis2_capi.h)
// plus the LocalAI backend implementation. Adapted from the upstream demo
// server's engine.go; the t2_abi_version binding guards against header/library
// drift.
import (
"fmt"
"math/rand"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/utils"
)
const abiVersion = 11
// Pipeline types (enum t2_pipeline_type) and background modes
// (enum t2_background_mode).
const (
pipeAuto = 0
pipeCoarse = 1
pipe512 = 2
pipe1024 = 3
backgroundAuto = 0
backgroundKeep = 1
backgroundBlack = 2
backgroundWhite = 3
)
// The bindings use typed pointers (*float32/*int32/*byte) rather than uintptr
// for C-owned buffers so no uintptr->unsafe.Pointer conversions are needed;
// only the opaque t2_pipeline / t2_mesh_result handles stay uintptr.
var (
t2AbiVersion func() int32
t2PipelineLoad func(dino, ssFlow, ssDec, slatFlow, slatFlowHR, shapeDec,
shapeEnc, texDec, texFlow, texFlowHR string,
flags int32, err *byte, errLen int32) uintptr
t2PipelineFree func(p uintptr)
t2PipelineBackend func(p uintptr) string
t2PipelineCaps func(p uintptr) int32
t2Generate func(p uintptr, img *byte, imgLen int32,
pipelineType, backgroundMode int32, seed uint64, steps int32,
guidance float32, textureSteps int32,
progress, user, preview, previewUser uintptr,
err *byte, errLen int32) uintptr
t2MeshNVerts func(r uintptr) int32
t2MeshNTris func(r uintptr) int32
t2MeshVerts func(r uintptr) *float32
t2MeshTris func(r uintptr) *int32
t2MeshHasPBR func(r uintptr) int32
t2MeshPBR func(r uintptr) *float32
t2MeshFree func(r uintptr)
t2BakeGLB func(verts *float32, nv int32, tris *int32, nt int32,
pbr *float32, texSize, componentFilter int32,
outLen *int32, err *byte, errLen int32) *byte
// CGAL Alpha Wrap print remeshing — availability is fixed at library build
// time, so gate every use on t2_print_remesh_available.
t2PrintRemeshAvailable func() int32
t2PreparePrintMesh func(verts *float32, nv int32, tris *int32, nt int32,
pbr *float32, componentFilter int32, alphaRatio, offsetRatio float32,
err *byte, errLen int32) uintptr
t2BakeProjectedGLB func(targetVerts *float32, targetNV int32,
targetTris *int32, targetNT int32,
sourceVerts *float32, sourceNV int32,
sourceTris *int32, sourceNT int32,
sourcePBR *float32, texSize, sourceComponentFilter int32,
outLen *int32, err *byte, errLen int32) *byte
t2FreeBuffer func(buf *byte)
)
type libFunc struct {
funcPtr any
name string
}
func registerLibFuncsWith(register func(fptr any, name string)) {
for _, lf := range []libFunc{
{&t2AbiVersion, "t2_abi_version"},
{&t2PipelineLoad, "t2_pipeline_load"},
{&t2PipelineFree, "t2_pipeline_free"},
{&t2PipelineBackend, "t2_pipeline_backend"},
{&t2PipelineCaps, "t2_pipeline_caps"},
{&t2Generate, "t2_generate"},
{&t2MeshNVerts, "t2_mesh_n_verts"},
{&t2MeshNTris, "t2_mesh_n_tris"},
{&t2MeshVerts, "t2_mesh_verts"},
{&t2MeshTris, "t2_mesh_tris"},
{&t2MeshHasPBR, "t2_mesh_has_pbr"},
{&t2MeshPBR, "t2_mesh_pbr"},
{&t2MeshFree, "t2_mesh_free"},
{&t2BakeGLB, "t2_bake_glb"},
{&t2PrintRemeshAvailable, "t2_print_remesh_available"},
{&t2PreparePrintMesh, "t2_prepare_print_mesh"},
{&t2BakeProjectedGLB, "t2_bake_projected_glb"},
{&t2FreeBuffer, "t2_free_buffer"},
} {
register(lf.funcPtr, lf.name)
}
}
// modelSet holds the resolved path for every pipeline role; optional roles are
// "" when disabled (the C side treats NULL/"" as "omit").
type modelSet struct {
dino, ssFlow, ssDec string
slatFlow, slatFlow1024 string
shapeDec string
shapeEnc, texDec string
texSlatFlow512, texSlatFlow1024 string
}
// role → (option key, default filename) in t2_pipeline_load argument order.
// The option keys follow the sd-ggml `*_path` convention; the default
// filenames are the ones the upstream converters emit and the demo server
// looks up, so a gallery install needs no options at all.
type modelRole struct {
key string
filename string
required bool
assign func(*modelSet, string)
}
var modelRoles = []modelRole{
{"dino_path", "dino_f16.gguf", true, func(s *modelSet, p string) { s.dino = p }},
{"ss_flow_path", "ss_flow_f16.gguf", true, func(s *modelSet, p string) { s.ssFlow = p }},
{"ss_dec_path", "ss_dec_f16.gguf", true, func(s *modelSet, p string) { s.ssDec = p }},
{"slat_flow_path", "slat_flow_f16.gguf", false, func(s *modelSet, p string) { s.slatFlow = p }},
{"slat_flow_1024_path", "slat_flow_1024_f16.gguf", false, func(s *modelSet, p string) { s.slatFlow1024 = p }},
{"shape_dec_path", "shape_dec_f16.gguf", false, func(s *modelSet, p string) { s.shapeDec = p }},
{"shape_enc_path", "shape_enc_f16.gguf", false, func(s *modelSet, p string) { s.shapeEnc = p }},
{"tex_dec_path", "tex_dec_f16.gguf", false, func(s *modelSet, p string) { s.texDec = p }},
{"tex_slat_flow_512_path", "tex_slat_flow_512_f16.gguf", false, func(s *modelSet, p string) { s.texSlatFlow512 = p }},
{"tex_slat_flow_1024_path", "tex_slat_flow_1024_f16.gguf", false, func(s *modelSet, p string) { s.texSlatFlow1024 = p }},
}
// resolveModels maps LocalAI's model file + options onto the ten pipeline
// roles. The model file only anchors the GGUF directory; each role resolves
// to an explicit `<role>_path` option when given, else to its default
// filename in that directory. Missing required files refuse the load (a
// backend must not capture arbitrary GGUFs — see issue #9287); missing
// optional files degrade capabilities the same way the upstream demo does.
func resolveModels(modelFile, modelPath string, options []string) (modelSet, error) {
base := modelFile
if !filepath.IsAbs(base) {
base = filepath.Join(modelPath, base)
}
ggufDir := filepath.Dir(base)
overrides := map[string]string{}
for _, op := range options {
key, value, found := strings.Cut(op, ":")
if !found || !strings.HasSuffix(key, "_path") {
continue
}
if !filepath.IsAbs(value) {
value = filepath.Join(modelPath, value)
if err := utils.VerifyPath(value, modelPath); err != nil {
return modelSet{}, fmt.Errorf("option %s: %w", key, err)
}
}
overrides[key] = value
}
var set modelSet
var missingRequired []string
for _, role := range modelRoles {
path, explicit := overrides[role.key]
if !explicit {
path = filepath.Join(ggufDir, role.filename)
}
if _, err := os.Stat(path); err != nil {
if explicit {
return modelSet{}, fmt.Errorf("option %s points at a missing file: %s", role.key, path)
}
if role.required {
missingRequired = append(missingRequired, role.filename)
}
path = ""
}
role.assign(&set, path)
}
if len(missingRequired) > 0 {
return modelSet{}, fmt.Errorf("not a trellis2 model set: missing required %s in %s", strings.Join(missingRequired, ", "), ggufDir)
}
// Degradation mirrors the upstream demo: the 512 pair enables everything
// finer than coarse; texturing needs its three-model set; a textured 1024
// cascade additionally needs the HR texture flow.
if set.slatFlow == "" || set.shapeDec == "" {
set.slatFlow, set.shapeDec = "", ""
set.slatFlow1024 = ""
set.shapeEnc, set.texDec, set.texSlatFlow512, set.texSlatFlow1024 = "", "", "", ""
return set, nil
}
if set.shapeEnc == "" || set.texDec == "" || set.texSlatFlow512 == "" {
set.shapeEnc, set.texDec, set.texSlatFlow512, set.texSlatFlow1024 = "", "", "", ""
} else if set.texSlatFlow1024 == "" {
set.slatFlow1024 = ""
}
return set, nil
}
func pipelineForQuality(quality string) int32 {
switch quality {
case "coarse":
return pipeCoarse
case "512":
return pipe512
case "1024":
return pipe1024
default:
return pipeAuto
}
}
func backgroundForMode(background string) int32 {
switch background {
case "keep":
return backgroundKeep
case "black":
return backgroundBlack
case "white":
return backgroundWhite
default:
return backgroundAuto
}
}
func componentFilterFor(components string) int32 {
switch components {
case "tiny":
return 0 // remove only tiny islands
case "largest":
return 1 // keep the largest connected component
default:
return 2 // preserve every connected component (demo default)
}
}
func atoiOr(s string, fallback int32) int32 {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return int32(n)
}
func boolParam(s string) bool {
return s == "1" || strings.EqualFold(s, "true")
}
// ratioOr parses a fraction-of-bounding-box-diagonal parameter. Out-of-range
// or unparseable values fall back rather than error, mirroring atoiOr; the
// accepted range matches what the upstream demo clamps to.
func ratioOr(s string, fallback float32) float32 {
if s == "" {
return fallback
}
f, err := strconv.ParseFloat(s, 32)
if err != nil || f < 0.00001 || f > 0.5 {
return fallback
}
return float32(f)
}
type Trellis2 struct {
base.SingleThread
// t2_generate is not thread-safe per pipeline. The gRPC server already
// serializes calls via Locking(), but keep a local mutex too so the
// invariant doesn't depend on the transport.
mu sync.Mutex
pipeline uintptr
}
func (t *Trellis2) Load(opts *pb.ModelOptions) error {
set, err := resolveModels(opts.ModelFile, opts.ModelPath, opts.Options)
if err != nil {
return err
}
errBuf := make([]byte, 512)
p := t2PipelineLoad(set.dino, set.ssFlow, set.ssDec,
set.slatFlow, set.slatFlow1024, set.shapeDec,
set.shapeEnc, set.texDec, set.texSlatFlow512, set.texSlatFlow1024,
0 /*flags*/, &errBuf[0], int32(len(errBuf)))
if p == 0 {
return fmt.Errorf("trellis2 pipeline load: %s", cstr(errBuf))
}
t.mu.Lock()
if t.pipeline != 0 {
t2PipelineFree(t.pipeline)
}
t.pipeline = p
t.mu.Unlock()
fmt.Fprintf(os.Stderr, "trellis2 pipeline loaded: backend=%s caps=%#x\n",
t2PipelineBackend(p), t2PipelineCaps(p))
return nil
}
func (t *Trellis2) Free() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.pipeline != 0 {
t2PipelineFree(t.pipeline)
t.pipeline = 0
}
return nil
}
func (t *Trellis2) Generate3D(opts *pb.Generate3DRequest) error {
if opts.Dst == "" {
return fmt.Errorf("dst is empty")
}
if opts.GetParams()["operation"] == "print_remesh" {
t.mu.Lock()
defer t.mu.Unlock()
return remeshGLB(opts)
}
img, err := os.ReadFile(opts.Src)
if err != nil {
return fmt.Errorf("reading conditioning image: %w", err)
}
if len(img) == 0 {
return fmt.Errorf("conditioning image is empty")
}
seed := uint64(opts.Seed)
if opts.Seed <= 0 {
seed = rand.Uint64()
}
guidance := opts.CfgScale
if guidance <= 0 {
guidance = -1 // <0 selects the pipeline default (7.5)
}
texSize := atoiOr(opts.GetParams()["texture_size"], 0) // <=0 selects the bake default
componentFilter := componentFilterFor(opts.GetParams()["components"])
// Optional CGAL Alpha Wrap: wrap the generated mesh into a watertight,
// intersection-free 2-manifold for 3D printing. Ratios are fractions of
// the bounding-box diagonal; offset defaults to alpha/30 per the CGAL
// guideline the upstream demo uses. Offset is deliberately not an
// independent parameter: looser values produce puffy or degenerate wraps.
printRemesh := boolParam(opts.GetParams()["print_remesh"])
alphaRatio := ratioOr(opts.GetParams()["alpha_ratio"], 0.005)
offsetRatio := alphaRatio / 30
if printRemesh && t2PrintRemeshAvailable() == 0 {
return fmt.Errorf("print_remesh requested but libtrellis2 was built without CGAL Alpha Wrap")
}
t.mu.Lock()
defer t.mu.Unlock()
if t.pipeline == 0 {
return fmt.Errorf("model not loaded")
}
errBuf := make([]byte, 512)
r := t2Generate(t.pipeline, &img[0], int32(len(img)),
pipelineForQuality(opts.Quality), backgroundForMode(opts.Background),
seed, opts.Step, guidance, opts.TextureSteps,
0, 0, 0, 0, // no progress/preview callbacks
&errBuf[0], int32(len(errBuf)))
if r == 0 {
return fmt.Errorf("trellis2 generate: %s", cstr(errBuf))
}
defer t2MeshFree(r)
nv := t2MeshNVerts(r)
nt := t2MeshNTris(r)
if nv == 0 || nt == 0 {
return fmt.Errorf("empty mesh")
}
var pbr *float32
if t2MeshHasPBR(r) != 0 {
pbr = t2MeshPBR(r)
}
// Bake straight from the mesh accessor buffers — they stay valid until
// t2_mesh_free, so no copies are needed.
var outLen int32
var glb *byte
if printRemesh {
wrap := t2PreparePrintMesh(t2MeshVerts(r), nv, t2MeshTris(r), nt, pbr,
componentFilter, alphaRatio, offsetRatio,
&errBuf[0], int32(len(errBuf)))
if wrap == 0 {
return fmt.Errorf("trellis2 print remesh: %s", cstr(errBuf))
}
defer t2MeshFree(wrap)
wnv, wnt := t2MeshNVerts(wrap), t2MeshNTris(wrap)
if wnv == 0 || wnt == 0 {
return fmt.Errorf("empty print mesh")
}
if pbr != nil {
// Wrapping creates new vertices, so the source material is
// reprojected per texel onto the wrap's UV atlas (demo handleGLB).
glb = t2BakeProjectedGLB(t2MeshVerts(wrap), wnv, t2MeshTris(wrap), wnt,
t2MeshVerts(r), nv, t2MeshTris(r), nt, pbr,
texSize, componentFilter,
&outLen, &errBuf[0], int32(len(errBuf)))
} else {
glb = t2BakeGLB(t2MeshVerts(wrap), wnv, t2MeshTris(wrap), wnt, nil,
texSize, 2, // the wrap output is already component-filtered
&outLen, &errBuf[0], int32(len(errBuf)))
}
} else {
glb = t2BakeGLB(t2MeshVerts(r), nv, t2MeshTris(r), nt, pbr,
texSize, componentFilter,
&outLen, &errBuf[0], int32(len(errBuf)))
}
if glb == nil {
return fmt.Errorf("trellis2 GLB bake: %s", cstr(errBuf))
}
defer t2FreeBuffer(glb)
out := make([]byte, int(outLen))
copy(out, unsafe.Slice(glb, int(outLen)))
return os.WriteFile(opts.Dst, out, 0600)
}
// remeshGLB applies the demo's post-generation print workflow to an existing
// dense vertex-PBR GLB. It does not touch the inference pipeline: CGAL wrapping,
// UV unwrapping, and PBR projection are CPU-only post-processing operations.
func remeshGLB(opts *pb.Generate3DRequest) error {
if opts.Src == "" {
return fmt.Errorf("src is empty")
}
if t2PrintRemeshAvailable() == 0 {
return fmt.Errorf("print remeshing is unavailable (libtrellis2 was built without CGAL Alpha Wrap)")
}
data, err := os.ReadFile(opts.Src)
if err != nil {
return fmt.Errorf("reading source GLB: %w", err)
}
mesh, err := parseVertexGLB(data)
if err != nil {
return fmt.Errorf("reading source GLB: %w", err)
}
params := opts.GetParams()
alphaRatio := ratioOr(params["alpha_ratio"], 0.005)
offsetRatio := alphaRatio / 30
componentFilter := componentFilterFor(params["components"])
textureSize := atoiOr(params["texture_size"], 2048)
var sourcePBR *float32
if len(mesh.pbr) != 0 {
sourcePBR = &mesh.pbr[0]
}
errBuf := make([]byte, 512)
wrap := t2PreparePrintMesh(
&mesh.verts[0], int32(len(mesh.verts)/3),
&mesh.tris[0], int32(len(mesh.tris)/3),
sourcePBR, componentFilter, alphaRatio, offsetRatio,
&errBuf[0], int32(len(errBuf)),
)
if wrap == 0 {
return fmt.Errorf("trellis2 print remesh: %s", cstr(errBuf))
}
defer t2MeshFree(wrap)
wrappedVerts, wrappedTris := t2MeshNVerts(wrap), t2MeshNTris(wrap)
if wrappedVerts == 0 || wrappedTris == 0 {
return fmt.Errorf("empty print mesh")
}
var outLen int32
var glb *byte
if sourcePBR != nil {
glb = t2BakeProjectedGLB(
t2MeshVerts(wrap), wrappedVerts, t2MeshTris(wrap), wrappedTris,
&mesh.verts[0], int32(len(mesh.verts)/3),
&mesh.tris[0], int32(len(mesh.tris)/3), sourcePBR,
int32(textureSize), componentFilter,
&outLen, &errBuf[0], int32(len(errBuf)),
)
} else {
glb = t2BakeGLB(
t2MeshVerts(wrap), wrappedVerts, t2MeshTris(wrap), wrappedTris,
nil, int32(textureSize), 2,
&outLen, &errBuf[0], int32(len(errBuf)),
)
}
if glb == nil || outLen <= 0 {
return fmt.Errorf("trellis2 GLB bake: %s", cstr(errBuf))
}
defer t2FreeBuffer(glb)
out := make([]byte, int(outLen))
copy(out, unsafe.Slice(glb, int(outLen)))
return os.WriteFile(opts.Dst, out, 0o600)
}
func cstr(b []byte) string {
for i, c := range b {
if c == 0 {
return string(b[:i])
}
}
return string(b)
}

View File

@@ -0,0 +1,247 @@
package main
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestTrellis2Cpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "trellis2cpp backend suite")
}
// touch creates empty files — resolveModels only checks existence, so the
// tests never need real GGUF weights.
func touch(dir string, names ...string) {
for _, name := range names {
Expect(os.WriteFile(filepath.Join(dir, name), nil, 0o600)).To(Succeed())
}
}
var requiredFiles = []string{"dino_f16.gguf", "ss_flow_f16.gguf", "ss_dec_f16.gguf"}
var fullSet = append(append([]string{}, requiredFiles...),
"slat_flow_f16.gguf", "slat_flow_1024_f16.gguf", "shape_dec_f16.gguf",
"shape_enc_f16.gguf", "tex_dec_f16.gguf",
"tex_slat_flow_512_f16.gguf", "tex_slat_flow_1024_f16.gguf")
var _ = Describe("resolveModels", func() {
var dir string
BeforeEach(func() {
dir = GinkgoT().TempDir()
})
It("refuses a directory without the trellis2 component files", func() {
touch(dir, "some-llm.gguf")
_, err := resolveModels("some-llm.gguf", dir, nil)
Expect(err).To(MatchError(ContainSubstring("not a trellis2 model set")))
})
It("resolves every role from the full default-named set", func() {
touch(dir, fullSet...)
set, err := resolveModels("ss_flow_f16.gguf", dir, nil)
Expect(err).NotTo(HaveOccurred())
for name, path := range map[string]string{
"dino": set.dino,
"ss_flow": set.ssFlow,
"ss_dec": set.ssDec,
"slat_flow": set.slatFlow,
"slat_flow_1024": set.slatFlow1024,
"shape_dec": set.shapeDec,
"shape_enc": set.shapeEnc,
"tex_dec": set.texDec,
"tex_slat_flow_512": set.texSlatFlow512,
"tex_slat_flow_1024": set.texSlatFlow1024,
} {
Expect(path).NotTo(BeEmpty(), "role %s", name)
}
})
It("degrades to coarse-only without the 512 pair, even when texture files exist", func() {
touch(dir, requiredFiles...)
touch(dir, "shape_enc_f16.gguf", "tex_dec_f16.gguf", "tex_slat_flow_512_f16.gguf", "slat_flow_1024_f16.gguf")
set, err := resolveModels("ss_flow_f16.gguf", dir, nil)
Expect(err).NotTo(HaveOccurred())
Expect(set.slatFlow).To(BeEmpty())
Expect(set.shapeDec).To(BeEmpty())
Expect(set.slatFlow1024).To(BeEmpty())
Expect(set.shapeEnc).To(BeEmpty())
Expect(set.texDec).To(BeEmpty())
Expect(set.texSlatFlow512).To(BeEmpty())
Expect(set.texSlatFlow1024).To(BeEmpty())
})
It("disables texturing but keeps fine geometry when the texture set is incomplete", func() {
touch(dir, requiredFiles...)
touch(dir, "slat_flow_f16.gguf", "slat_flow_1024_f16.gguf", "shape_dec_f16.gguf", "tex_dec_f16.gguf")
set, err := resolveModels("ss_flow_f16.gguf", dir, nil)
Expect(err).NotTo(HaveOccurred())
Expect(set.slatFlow).NotTo(BeEmpty())
Expect(set.shapeDec).NotTo(BeEmpty())
Expect(set.slatFlow1024).NotTo(BeEmpty())
Expect(set.shapeEnc).To(BeEmpty())
Expect(set.texDec).To(BeEmpty())
Expect(set.texSlatFlow512).To(BeEmpty())
Expect(set.texSlatFlow1024).To(BeEmpty())
})
It("drops the 1024 cascade when texturing lacks the HR texture flow", func() {
touch(dir, fullSet...)
Expect(os.Remove(filepath.Join(dir, "tex_slat_flow_1024_f16.gguf"))).To(Succeed())
set, err := resolveModels("ss_flow_f16.gguf", dir, nil)
Expect(err).NotTo(HaveOccurred())
Expect(set.slatFlow1024).To(BeEmpty())
Expect(set.shapeEnc).NotTo(BeEmpty())
Expect(set.texDec).NotTo(BeEmpty())
Expect(set.texSlatFlow512).NotTo(BeEmpty())
})
It("honors explicit *_path option overrides", func() {
touch(dir, fullSet...)
custom := filepath.Join(dir, "custom")
Expect(os.Mkdir(custom, 0o750)).To(Succeed())
touch(custom, "my-dino.gguf")
set, err := resolveModels("ss_flow_f16.gguf", dir, []string{"dino_path:custom/my-dino.gguf"})
Expect(err).NotTo(HaveOccurred())
Expect(set.dino).To(Equal(filepath.Join(custom, "my-dino.gguf")))
})
It("fails when an explicitly configured file is missing", func() {
touch(dir, fullSet...)
_, err := resolveModels("ss_flow_f16.gguf", dir, []string{"tex_dec_path:nope.gguf"})
Expect(err).To(MatchError(ContainSubstring("missing file")))
})
It("rejects option paths escaping the model directory", func() {
touch(dir, fullSet...)
_, err := resolveModels("ss_flow_f16.gguf", dir, []string{"dino_path:../outside.gguf"})
Expect(err).To(HaveOccurred())
})
})
var _ = DescribeTable("request parameter mapping",
func(got, want int32) {
Expect(got).To(Equal(want))
},
Entry("quality empty", pipelineForQuality(""), int32(pipeAuto)),
Entry("quality auto", pipelineForQuality("auto"), int32(pipeAuto)),
Entry("quality coarse", pipelineForQuality("coarse"), int32(pipeCoarse)),
Entry("quality 512", pipelineForQuality("512"), int32(pipe512)),
Entry("quality 1024", pipelineForQuality("1024"), int32(pipe1024)),
Entry("background empty", backgroundForMode(""), int32(backgroundAuto)),
Entry("background auto", backgroundForMode("auto"), int32(backgroundAuto)),
Entry("background keep", backgroundForMode("keep"), int32(backgroundKeep)),
Entry("background black", backgroundForMode("black"), int32(backgroundBlack)),
Entry("background white", backgroundForMode("white"), int32(backgroundWhite)),
Entry("components default", componentFilterFor(""), int32(2)),
Entry("components all", componentFilterFor("all"), int32(2)),
Entry("components largest", componentFilterFor("largest"), int32(1)),
Entry("components tiny", componentFilterFor("tiny"), int32(0)),
Entry("atoi empty", atoiOr("", 0), int32(0)),
Entry("atoi value", atoiOr("2048", 0), int32(2048)),
Entry("atoi junk", atoiOr("junk", 7), int32(7)),
)
var _ = Describe("print remesh parameters", func() {
It("parses the print_remesh toggle", func() {
Expect(boolParam("1")).To(BeTrue())
Expect(boolParam("true")).To(BeTrue())
Expect(boolParam("TRUE")).To(BeTrue())
Expect(boolParam("")).To(BeFalse())
Expect(boolParam("0")).To(BeFalse())
Expect(boolParam("no")).To(BeFalse())
})
It("parses ratios and clamps junk to the fallback", func() {
Expect(ratioOr("", 0.005)).To(BeNumerically("~", 0.005, 1e-6))
Expect(ratioOr("0.01", 0.005)).To(BeNumerically("~", 0.01, 1e-6))
Expect(ratioOr("junk", 0.005)).To(BeNumerically("~", 0.005, 1e-6))
Expect(ratioOr("-1", 0.005)).To(BeNumerically("~", 0.005, 1e-6))
Expect(ratioOr("0.9", 0.005)).To(BeNumerically("~", 0.005, 1e-6), "above the demo's 50% cap")
})
})
var _ = Describe("packaged backend", func() {
It("starts and answers Health without loading model weights", func() {
runScript := os.Getenv("TRELLIS2CPP_SMOKE_RUN")
if runScript == "" {
runScript = filepath.Join("package", "run.sh")
}
if _, err := os.Stat(runScript); os.IsNotExist(err) {
Skip("packaged backend is not present; run make before the smoke test")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).NotTo(HaveOccurred())
addr := listener.Addr().String()
Expect(listener.Close()).To(Succeed())
cmd := exec.Command("bash", runScript, "--addr="+addr)
cmd.Stdout = GinkgoWriter
cmd.Stderr = GinkgoWriter
Expect(cmd.Start()).To(Succeed())
processDone := make(chan error, 1)
go func() { processDone <- cmd.Wait() }()
processExited := false
DeferCleanup(func() {
if cmd.Process != nil && !processExited {
_ = cmd.Process.Kill()
<-processDone
}
})
Eventually(func() error {
select {
case err := <-processDone:
processExited = true
if err != nil {
return StopTrying("backend exited before Health succeeded").Wrap(err)
}
return StopTrying("backend exited before Health succeeded")
default:
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
reply, err := pb.NewBackendClient(conn).Health(ctx, &pb.HealthMessage{})
if err != nil {
return err
}
if string(reply.GetMessage()) != "OK" {
return fmt.Errorf("unexpected Health reply %q", reply.GetMessage())
}
return nil
}, 30*time.Second, 200*time.Millisecond).Should(Succeed())
})
})

View File

@@ -425,6 +425,32 @@
nvidia-cuda-12: "cuda12-stablediffusion-ggml"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-stablediffusion-ggml"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-stablediffusion-ggml"
- &trellis2cpp
name: "trellis2cpp"
alias: "trellis2cpp"
license: mit
description: |
TRELLIS.2 image-to-3D generation (GLB meshes with PBR textures) in C++/ggml
urls:
- https://github.com/localai-org/trellis2cpp
- https://github.com/microsoft/TRELLIS.2
tags:
- image-to-3d
- 3d-generation
- CPU
- GPU
- CUDA
- Metal
capabilities:
default: "cpu-trellis2cpp"
nvidia: "cuda12-trellis2cpp"
vulkan: "vulkan-trellis2cpp"
nvidia-l4t: "nvidia-l4t-arm64-trellis2cpp"
metal: "metal-trellis2cpp"
nvidia-cuda-13: "cuda13-trellis2cpp"
nvidia-cuda-12: "cuda12-trellis2cpp"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-trellis2cpp"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-trellis2cpp"
- &rfdetr
name: "rfdetr"
alias: "rfdetr"
@@ -1972,6 +1998,18 @@
nvidia-cuda-12: "cuda12-stablediffusion-ggml-development"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-stablediffusion-ggml-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-stablediffusion-ggml-development"
- !!merge <<: *trellis2cpp
name: "trellis2cpp-development"
capabilities:
default: "cpu-trellis2cpp-development"
nvidia: "cuda12-trellis2cpp-development"
vulkan: "vulkan-trellis2cpp-development"
nvidia-l4t: "nvidia-l4t-arm64-trellis2cpp-development"
metal: "metal-trellis2cpp-development"
nvidia-cuda-13: "cuda13-trellis2cpp-development"
nvidia-cuda-12: "cuda12-trellis2cpp-development"
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-trellis2cpp-development"
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-trellis2cpp-development"
- !!merge <<: *neutts
name: "cpu-neutts"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-neutts"
@@ -3688,6 +3726,77 @@
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-stablediffusion-ggml
## trellis2cpp
- !!merge <<: *trellis2cpp
name: "cpu-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-trellis2cpp"
mirrors:
- localai/localai-backends:latest-cpu-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cpu-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-trellis2cpp"
mirrors:
- localai/localai-backends:master-cpu-trellis2cpp
- !!merge <<: *trellis2cpp
name: "metal-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:latest-metal-darwin-arm64-trellis2cpp
- !!merge <<: *trellis2cpp
name: "metal-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:master-metal-darwin-arm64-trellis2cpp
- !!merge <<: *trellis2cpp
name: "vulkan-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-trellis2cpp"
mirrors:
- localai/localai-backends:latest-gpu-vulkan-trellis2cpp
- !!merge <<: *trellis2cpp
name: "vulkan-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-vulkan-trellis2cpp"
mirrors:
- localai/localai-backends:master-gpu-vulkan-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda12-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-trellis2cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-12-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda12-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-trellis2cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-12-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda13-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-trellis2cpp"
mirrors:
- localai/localai-backends:latest-gpu-nvidia-cuda-13-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda13-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-trellis2cpp"
mirrors:
- localai/localai-backends:master-gpu-nvidia-cuda-13-trellis2cpp
- !!merge <<: *trellis2cpp
name: "nvidia-l4t-arm64-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-arm64-trellis2cpp
- !!merge <<: *trellis2cpp
name: "nvidia-l4t-arm64-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-arm64-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda13-nvidia-l4t-arm64-trellis2cpp"
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-trellis2cpp
- !!merge <<: *trellis2cpp
name: "cuda13-nvidia-l4t-arm64-trellis2cpp-development"
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-trellis2cpp"
mirrors:
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-trellis2cpp
## privacy-filter
- !!merge <<: *privacyfilter
name: "cpu-privacy-filter"

View File

@@ -334,6 +334,13 @@ impl Backend for KokorosService {
Err(Status::unimplemented("Not supported"))
}
async fn generate3_d(
&self,
_: Request<backend::Generate3DRequest>,
) -> Result<Response<backend::Result>, Status> {
Err(Status::unimplemented("Not supported"))
}
async fn audio_transcription(
&self,
_: Request<backend::TranscriptRequest>,

104
core/backend/model3d.go Normal file
View File

@@ -0,0 +1,104 @@
package backend
import (
"maps"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
)
// Model3DGenerationOptions is the backend-neutral request passed to 3D
// generators. Image contains a staged local path by the time it reaches
// this layer.
type Model3DGenerationOptions struct {
Image string
Destination string
Seed int32
Step int32
CFGScale float32
TextureSteps int32
Quality string
Background string
Params map[string]string
}
func Model3DGeneration(options Model3DGenerationOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
opts := ModelOptions(modelConfig, appConfig)
inferenceModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
fn := func() error {
_, err := inferenceModel.Generate3D(
appConfig.Context,
&proto.Generate3DRequest{
Src: options.Image,
Dst: options.Destination,
Seed: options.Seed,
Step: options.Step,
CfgScale: options.CFGScale,
TextureSteps: options.TextureSteps,
Quality: options.Quality,
Background: options.Background,
Params: maps.Clone(options.Params),
},
)
return err
}
if appConfig.EnableTracing {
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
traceType := trace.BackendTrace3DGeneration
traceSummary := "3d: " + options.Quality
traceData := map[string]any{}
if options.Params["operation"] == "print_remesh" {
traceType = trace.BackendTrace3DRemesh
traceSummary = "3d: remesh"
traceData["detail_percent"] = options.Params["detail_percent"]
traceData["has_mesh"] = options.Image != ""
} else {
traceData = map[string]any{
"seed": options.Seed,
"step": options.Step,
"cfg_scale": options.CFGScale,
"texture_steps": options.TextureSteps,
"quality": options.Quality,
"background": options.Background,
"has_image": options.Image != "",
}
}
startTime := time.Now()
originalFn := fn
fn = func() error {
err := originalFn()
duration := time.Since(startTime)
errStr := ""
if err != nil {
errStr = err.Error()
}
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: startTime,
Duration: duration,
Type: traceType,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(traceSummary, 200),
Error: errStr,
Data: traceData,
})
return err
}
}
return fn, nil
}

View File

@@ -16,6 +16,7 @@ const (
UsecaseTokenize = "tokenize"
UsecaseImage = "image"
UsecaseVideo = "video"
Usecase3D = "3d"
UsecaseTranscript = "transcript"
UsecaseTTS = "tts"
UsecaseSoundGeneration = "sound_generation"
@@ -42,6 +43,7 @@ const (
MethodEmbedding GRPCMethod = "Embedding"
MethodGenerateImage GRPCMethod = "GenerateImage"
MethodGenerateVideo GRPCMethod = "GenerateVideo"
MethodGenerate3D GRPCMethod = "Generate3D"
MethodAudioTranscription GRPCMethod = "AudioTranscription"
MethodTTS GRPCMethod = "TTS"
MethodTTSStream GRPCMethod = "TTSStream"
@@ -124,6 +126,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodGenerateVideo,
Description: "Video generation via the GenerateVideo RPC, with optional image or audio conditioning when supported by the backend.",
},
Usecase3D: {
Flag: FLAG_3D,
GRPCMethod: MethodGenerate3D,
Description: "Image-conditioned 3D asset generation via the Generate3D RPC — a binary glTF (GLB) mesh with optional PBR material (TRELLIS.2).",
},
UsecaseTranscript: {
Flag: FLAG_TRANSCRIPT,
GRPCMethod: MethodAudioTranscription,
@@ -351,6 +358,14 @@ var BackendCapabilities = map[string]BackendCapability{
Description: "Stable Diffusion via GGML quantized models",
},
// --- 3D generation backends ---
"trellis2cpp": {
GRPCMethods: []GRPCMethod{MethodGenerate3D},
PossibleUsecases: []string{Usecase3D},
DefaultUsecases: []string{Usecase3D},
Description: "trellis2.cpp — C++/GGML port of Microsoft TRELLIS.2: single-image to textured 3D mesh (GLB)",
},
// --- Speech-to-text backends ---
"whisper": {
GRPCMethods: []GRPCMethod{MethodAudioTranscription, MethodVAD},

View File

@@ -15,9 +15,10 @@ const (
ModalityImage = "image"
ModalityAudio = "audio"
ModalityVideo = "video"
Modality3D = "3d"
)
var modalityOrder = []string{ModalityText, ModalityImage, ModalityAudio, ModalityVideo}
var modalityOrder = []string{ModalityText, ModalityImage, ModalityAudio, ModalityVideo, Modality3D}
func declaredModalities(modalities []string) map[string]bool {
declared := make(map[string]bool, len(modalities))
@@ -154,6 +155,7 @@ func (c *ModelConfig) Capabilities() []string {
add(c.HasUsecases(FLAG_SOUND_GENERATION), UsecaseSoundGeneration)
add(c.HasUsecases(FLAG_IMAGE), UsecaseImage)
add(c.HasUsecases(FLAG_VIDEO), UsecaseVideo)
add(c.HasUsecases(FLAG_3D), Usecase3D)
add(c.HasUsecases(FLAG_VAD), UsecaseVAD)
add(c.HasUsecases(FLAG_DETECTION), UsecaseDetection)
add(c.HasUsecases(FLAG_DEPTH), UsecaseDepth)
@@ -181,9 +183,10 @@ func (c *ModelConfig) InputModalities() []string {
c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) || imageGen || videoGen
// Image input via a chat model requires vision (gated on chat, like the
// Ollama surface); detection/depth/face models consume images directly.
// Ollama surface); detection/depth/face/3D models consume images directly.
imageIn := (chatish && c.VisionSupported()) || c.LimitMMPerPrompt.LimitImagePerPrompt > 0 ||
c.HasUsecases(FLAG_DETECTION) || c.HasUsecases(FLAG_DEPTH) || c.HasUsecases(FLAG_FACE_RECOGNITION)
c.HasUsecases(FLAG_DETECTION) || c.HasUsecases(FLAG_DEPTH) || c.HasUsecases(FLAG_FACE_RECOGNITION) ||
c.HasUsecases(FLAG_3D)
audioIn := c.AudioInputSupported() || c.HasUsecases(FLAG_TRANSCRIPT) || c.HasUsecases(FLAG_AUDIO_TRANSFORM) ||
c.HasUsecases(FLAG_REALTIME_AUDIO) || c.HasUsecases(FLAG_VAD) || c.HasUsecases(FLAG_DIARIZATION) ||
@@ -208,10 +211,12 @@ func (c *ModelConfig) OutputModalities() []string {
audioOut := c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) ||
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
videoOut := c.HasUsecases(FLAG_VIDEO)
threeDOut := c.HasUsecases(FLAG_3D)
modalities[ModalityText] = modalities[ModalityText] || textOut
modalities[ModalityImage] = modalities[ModalityImage] || imageOut
modalities[ModalityAudio] = modalities[ModalityAudio] || audioOut
modalities[ModalityVideo] = modalities[ModalityVideo] || videoOut
modalities[Modality3D] = modalities[Modality3D] || threeDOut
return orderedModalities(modalities)
}

View File

@@ -109,6 +109,25 @@ var _ = Describe("Model capabilities derivation", func() {
Expect(cfg.OutputModalities()).To(Equal([]string{"image"}))
})
It("guesses the 3d usecase from the trellis2cpp backend and only that backend", func() {
cfg := &ModelConfig{Backend: "trellis2cpp"}
Expect(cfg.HasUsecases(FLAG_3D)).To(BeTrue())
Expect(cfg.Capabilities()).To(ContainElement(Usecase3D))
other := &ModelConfig{Backend: "llama-cpp"}
Expect(other.HasUsecases(FLAG_3D)).To(BeFalse())
})
It("a 3D-generation model reads an image and writes a 3D asset", func() {
// Pins the wire strings the UI depends on: capability "3d",
// input modality "image" (no text prompt — TRELLIS.2 is
// image-conditioned only), output modality "3d".
cfg := &ModelConfig{KnownUsecases: usecaseBits(FLAG_3D), Backend: "trellis2cpp"}
Expect(cfg.Capabilities()).To(Equal([]string{Usecase3D}))
Expect(cfg.InputModalities()).To(Equal([]string{ModalityImage}))
Expect(cfg.OutputModalities()).To(Equal([]string{Modality3D}))
})
It("conditioned video uses declared modalities without backend-specific inference", func() {
cfg := &ModelConfig{
KnownUsecases: usecaseBits(FLAG_VIDEO),

View File

@@ -1673,6 +1673,11 @@ const (
// labels via the SoundDetection RPC, e.g. ced).
FLAG_SOUND_CLASSIFICATION ModelConfigUsecase = 0b10000000000000000000000
// Marks a model as wired for the Generate3D gRPC primitive
// (image-conditioned 3D asset generation — a binary glTF mesh with
// optional PBR material, e.g. trellis2cpp).
FLAG_3D ModelConfigUsecase = 0b100000000000000000000000
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
)
@@ -1686,7 +1691,7 @@ var ModalityGroups = []ModelConfigUsecase{
FLAG_TRANSCRIPT | FLAG_REALTIME_AUDIO | FLAG_SOUND_CLASSIFICATION, // audio input — realtime_audio is any-to-any, so it counts here too
FLAG_TTS | FLAG_SOUND_GENERATION | FLAG_REALTIME_AUDIO, // audio output — and here, so a lone realtime_audio flag still reads as multimodal
FLAG_AUDIO_TRANSFORM, // audio in/out transforms
FLAG_IMAGE | FLAG_VIDEO, // visual generation
FLAG_IMAGE | FLAG_VIDEO | FLAG_3D, // visual generation
}
// IsMultimodal returns true if the given usecases span two or more orthogonal
@@ -1733,6 +1738,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
"FLAG_SCORE": FLAG_SCORE,
"FLAG_DEPTH": FLAG_DEPTH,
"FLAG_TOKEN_CLASSIFY": FLAG_TOKEN_CLASSIFY,
"FLAG_3D": FLAG_3D,
}
}
@@ -1884,6 +1890,13 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
}
}
if (u & FLAG_3D) == FLAG_3D {
threeDBackends := []string{"trellis2cpp"}
if !slices.Contains(threeDBackends, c.Backend) {
return false
}
}
if (u & FLAG_FACE_RECOGNITION) == FLAG_FACE_RECOGNITION {
faceBackends := []string{"insightface"}
if !slices.Contains(faceBackends, c.Backend) {

View File

@@ -143,6 +143,11 @@ var defaultImporters = []Importer{
&CoquiImporter{},
// Image/Video (Batch 3)
&StableDiffusionGGMLImporter{},
// Trellis2CppImporter (TRELLIS.2 image-to-3D, native C++/ggml port) must
// run before LlamaCPPImporter so its GGUF sets aren't claimed by the
// generic .gguf importer; matches only trellis-named URIs/repos or the
// distinctive component filenames, so arbitrary GGUFs are never claimed.
&Trellis2CppImporter{},
&ACEStepImporter{},
// LongCat repositories carry generic Diffusers metadata, so this exact
// owner/repo matcher must run before DiffuserImporter.

View File

@@ -0,0 +1,170 @@
package importers
import (
"encoding/json"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/schema"
"go.yaml.in/yaml/v2"
)
var _ Importer = &Trellis2CppImporter{}
// trellis2File describes one component of the TRELLIS.2 GGUF set hosted on
// the LocalAI-io HuggingFace org. The pipeline spans three source repos
// (TRELLIS.2-4B, TRELLIS-image-large for the SS decoder, and a DINOv3
// mirror), so a single import URI always expands to this full set — no one
// repo can describe it alone. Filenames follow the trellis2cpp converter
// defaults, which the backend resolves without any options.
type trellis2File struct {
filename string
uri string
sha256 string
}
var trellis2Files = []trellis2File{
{"dino_f16.gguf", "https://huggingface.co/LocalAI-io/dinov3-vitl16-pretrain-lvd1689m-GGUF/resolve/main/dino_f16.gguf", "385d8186a38a2328ec740fb2ac1f33f9194d8774efc7ccafd4aa2e51cf5f6450"},
{"ss_flow_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/ss_flow_f16.gguf", "1dded5b74237d24e6876a642a26f90b43742e3554418573860f810e3bbe61e8c"},
{"ss_dec_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS-image-large-GGUF/resolve/main/ss_dec_f16.gguf", "9c2210b7ed830fdc8286961a8189878ff5bcfd3bfc83ab4eacee005d293d2185"},
{"slat_flow_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/slat_flow_f16.gguf", "2f94bad7b1c524ad8c01943bc38fcc0c314e7d482ce896f3c6e96eb6e7cec15c"},
{"slat_flow_1024_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/slat_flow_1024_f16.gguf", "b6a2270131e2e9235e9b6cb525193eb85ae132fa5af3274322aacd39e40a6bc5"},
{"shape_dec_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/shape_dec_f16.gguf", "6fe53f1d7763dabf7c8d72bc38f4053d87fde6f65bf17a9d378d27edb39d3530"},
{"shape_enc_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/shape_enc_f16.gguf", "3ec80ff580987fcdb9bc594fc8b6fda890d63101ca442eb2b26f5dc315e8696c"},
{"tex_dec_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/tex_dec_f16.gguf", "afd304f4dfcb8c94df851b85519b415b99f04070f7d29de1320c50631b1be4e0"},
{"tex_slat_flow_512_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/tex_slat_flow_512_f16.gguf", "89a081b7f5487a5b31f03d240e4d959a56db0cc2c46c327230097a2554da52ae"},
{"tex_slat_flow_1024_f16.gguf", "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF/resolve/main/tex_slat_flow_1024_f16.gguf", "bbb55b0910c7929aac5e0612a9bb15113837a2c674cafb9f0f170eda8b5558a8"},
}
// trellis2ComponentNames are the distinctive default component filenames. A
// raw .gguf URL with one of these basenames is a strong trellis2 signal.
// dino_f16.gguf is deliberately absent — DINO checkpoints are common enough
// that the bare name would over-claim.
var trellis2ComponentNames = map[string]struct{}{
"ss_flow_f16.gguf": {},
"ss_dec_f16.gguf": {},
"slat_flow_f16.gguf": {},
"slat_flow_1024_f16.gguf": {},
"shape_dec_f16.gguf": {},
"shape_enc_f16.gguf": {},
"tex_dec_f16.gguf": {},
"tex_slat_flow_512_f16.gguf": {},
"tex_slat_flow_1024_f16.gguf": {},
}
// Trellis2CppImporter recognises Microsoft TRELLIS.2 image-to-3D GGUF sets
// (the trellis2.cpp converter outputs hosted under LocalAI-io). It must be
// registered BEFORE LlamaCPPImporter so llama-cpp does not steal the .gguf
// match. preferences.backend="trellis2cpp" overrides detection.
type Trellis2CppImporter struct{}
func (i *Trellis2CppImporter) Name() string { return "trellis2cpp" }
func (i *Trellis2CppImporter) Modality() string { return "3d" }
func (i *Trellis2CppImporter) AutoDetects() bool { return true }
// containsTrellisToken reports whether s (compared case-insensitively)
// carries a TRELLIS marker ("trellis" covers TRELLIS.2 / trellis2 too).
func containsTrellisToken(s string) bool {
return strings.Contains(strings.ToLower(s), "trellis")
}
func (i *Trellis2CppImporter) Match(details Details) bool {
preferences, err := details.Preferences.MarshalJSON()
if err != nil {
return false
}
preferencesMap := make(map[string]any)
if len(preferences) > 0 {
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
return false
}
}
if b, ok := preferencesMap["backend"].(string); ok && b != "" {
return b == "trellis2cpp"
}
// Raw .gguf URL named after a distinctive pipeline component.
if strings.HasSuffix(strings.ToLower(details.URI), ".gguf") {
base := strings.ToLower(filepath.Base(details.URI))
if _, ok := trellis2ComponentNames[base]; ok {
return true
}
}
// A trellis-named URI or HF repo carrying GGUFs.
if containsTrellisToken(details.URI) {
if strings.HasSuffix(strings.ToLower(details.URI), ".gguf") {
return true
}
if details.HuggingFace != nil && hasGGUF(details.HuggingFace.Files) {
return true
}
// HF details may be nil (tree-listing quirk) — decide from the
// owner/repo alone.
if _, repo, ok := HFOwnerRepoFromURI(details.URI); ok && containsTrellisToken(repo) {
return true
}
}
return false
}
func (i *Trellis2CppImporter) Import(details Details) (gallery.ModelConfig, error) {
preferences, err := details.Preferences.MarshalJSON()
if err != nil {
return gallery.ModelConfig{}, err
}
preferencesMap := make(map[string]any)
if len(preferences) > 0 {
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
return gallery.ModelConfig{}, err
}
}
name, ok := preferencesMap["name"].(string)
if !ok {
name = "trellis2-4b"
}
description, ok := preferencesMap["description"].(string)
if !ok {
description = "TRELLIS.2 image-to-3D (GLB with PBR textures) — imported from " + details.URI
}
cfg := gallery.ModelConfig{
Name: name,
Description: description,
}
// The full pipeline spans three HF repos, so any trellis URI imports the
// complete known-good set rather than whatever single repo was pasted.
for _, f := range trellis2Files {
cfg.Files = append(cfg.Files, gallery.File{
URI: f.uri,
Filename: f.filename,
SHA256: f.sha256,
})
}
modelConfig := config.ModelConfig{
Name: name,
Description: description,
Backend: "trellis2cpp",
KnownUsecaseStrings: []string{"FLAG_3D"},
PredictionOptions: schema.PredictionOptions{
// ss_flow anchors the GGUF directory; the backend resolves the
// other components from their default filenames next to it.
BasicModelRequest: schema.BasicModelRequest{Model: "ss_flow_f16.gguf"},
},
}
data, err := yaml.Marshal(modelConfig)
if err != nil {
return gallery.ModelConfig{}, err
}
cfg.ConfigFile = string(data)
return cfg, nil
}

View File

@@ -0,0 +1,105 @@
package importers_test
import (
"encoding/json"
"fmt"
"github.com/mudler/LocalAI/core/gallery/importers"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Trellis2CppImporter", func() {
Context("detection from HuggingFace", func() {
// LocalAI-io/TRELLIS.2-4B-GGUF is the canonical GGUF conversion of
// microsoft/TRELLIS.2-4B produced by the trellis2cpp converters.
// Detection must route it to trellis2cpp (and NOT to llama-cpp,
// which otherwise steals every .gguf repo).
It("matches the TRELLIS.2 GGUF repo and imports the full component set", func() {
uri := "https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF"
preferences := json.RawMessage(`{}`)
modelConfig, err := importers.DiscoverModelConfig(uri, preferences)
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("Error: %v", err))
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: trellis2cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
Expect(modelConfig.ConfigFile).To(ContainSubstring("known_usecases"))
Expect(modelConfig.ConfigFile).To(ContainSubstring("FLAG_3D"))
// The pipeline spans three repos; the import must carry the whole
// set, anchored on ss_flow.
Expect(modelConfig.Files).To(HaveLen(10))
Expect(modelConfig.ConfigFile).To(ContainSubstring("model: ss_flow_f16.gguf"))
})
It("matches a raw .gguf URL named after a distinctive pipeline component", func() {
uri := "https://example.com/models/tex_slat_flow_512_f16.gguf"
preferences := json.RawMessage(`{}`)
modelConfig, err := importers.DiscoverModelConfig(uri, preferences)
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("Error: %v", err))
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: trellis2cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
})
})
Context("preference override", func() {
It("honours preferences.backend=trellis2cpp for arbitrary URIs", func() {
uri := "https://example.com/some-unrelated-model"
preferences := json.RawMessage(`{"backend": "trellis2cpp"}`)
modelConfig, err := importers.DiscoverModelConfig(uri, preferences)
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("Error: %v", err))
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: trellis2cpp"), fmt.Sprintf("Model config: %+v", modelConfig))
})
It("does not override a different explicit backend", func() {
imp := &importers.Trellis2CppImporter{}
match := imp.Match(importers.Details{
URI: "https://example.com/models/tex_slat_flow_512_f16.gguf",
Preferences: json.RawMessage(`{"backend": "llama-cpp"}`),
})
Expect(match).To(BeFalse())
})
It("still auto-detects when the backend preference is empty", func() {
imp := &importers.Trellis2CppImporter{}
match := imp.Match(importers.Details{
URI: "https://example.com/models/tex_slat_flow_512_f16.gguf",
Preferences: json.RawMessage(`{"backend": ""}`),
})
Expect(match).To(BeTrue())
})
})
Context("negative detection", func() {
It("does not claim an unrelated raw .gguf URL", func() {
imp := &importers.Trellis2CppImporter{}
match := imp.Match(importers.Details{
URI: "https://example.com/models/llama-3-8b-Q4_K.gguf",
Preferences: json.RawMessage(`{}`),
})
Expect(match).To(BeFalse())
})
It("does not claim a bare dino_f16.gguf (too generic a name)", func() {
imp := &importers.Trellis2CppImporter{}
match := imp.Match(importers.Details{
URI: "https://example.com/models/dino_f16.gguf",
Preferences: json.RawMessage(`{}`),
})
Expect(match).To(BeFalse())
})
})
Context("Importer interface metadata", func() {
It("exposes name/modality/autodetect", func() {
imp := &importers.Trellis2CppImporter{}
Expect(imp.Name()).To(Equal("trellis2cpp"))
Expect(imp.Modality()).To(Equal("3d"))
Expect(imp.AutoDetects()).To(BeTrue())
})
})
})

View File

@@ -55,6 +55,12 @@ var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/rea
// conditional revalidation round-trip.
const immutableAssetCacheControl = "public, max-age=31536000, immutable"
func defaultBodyLimitSkipper(c echo.Context) bool {
// Remeshing accepts generated GLBs that routinely exceed the default
// upload limit. The route has its own tighter, format-specific limit.
return c.Request().Method == http.MethodPost && c.Path() == "/3d/remesh"
}
// applyModelLoadCooldown maps a ModelLoadCooldownError anywhere in err's chain
// to HTTP 503 with a Retry-After header (whole seconds, floor 1), so a client
// polling a model whose load recently failed backs off instead of triggering a
@@ -123,7 +129,10 @@ func API(application *application.Application) (*echo.Echo, error) {
// Set body limit
if application.ApplicationConfig().UploadLimitMB > 0 {
e.Use(middleware.BodyLimit(fmt.Sprintf("%dM", application.ApplicationConfig().UploadLimitMB)))
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
Limit: fmt.Sprintf("%dM", application.ApplicationConfig().UploadLimitMB),
Skipper: defaultBodyLimitSkipper,
}))
}
// SPA fallback handler, set later when React UI is available
@@ -305,14 +314,22 @@ func API(application *application.Application) (*echo.Echo, error) {
audioPath := filepath.Join(application.ApplicationConfig().GeneratedContentDir, "audio")
imagePath := filepath.Join(application.ApplicationConfig().GeneratedContentDir, "images")
videoPath := filepath.Join(application.ApplicationConfig().GeneratedContentDir, "videos")
threeDPath := filepath.Join(application.ApplicationConfig().GeneratedContentDir, "3d")
os.MkdirAll(audioPath, 0750)
os.MkdirAll(imagePath, 0750)
os.MkdirAll(videoPath, 0750)
_ = os.MkdirAll(threeDPath, 0750)
// Go's built-in MIME table has no .glb entry and minimal containers
// ship no /etc/mime.types, so generated GLBs would otherwise be
// served as application/octet-stream.
_ = mime.AddExtensionType(".glb", "model/gltf-binary")
e.Static("/generated-audio", audioPath)
e.Static("/generated-images", imagePath)
e.Static("/generated-videos", videoPath)
e.Static("/generated-3d", threeDPath)
}
// Usage recording is initialised in application/startup.go and

View File

@@ -91,6 +91,10 @@ var RouteFeatureRegistry = []RouteFeature{
// Video
{"POST", "/video", FeatureVideo},
// 3D generation
{"POST", "/3d/generations", Feature3D},
{"POST", "/3d/remesh", Feature3D},
// Sound generation
{"POST", "/v1/sound-generation", FeatureSound},
@@ -182,6 +186,7 @@ func APIFeatureMetas() []FeatureMeta {
{FeatureVAD, "Voice Activity Detection", true},
{FeatureDetection, "Detection", true},
{FeatureVideo, "Video Generation", true},
{Feature3D, "3D Generation", true},
{FeatureEmbeddings, "Embeddings", true},
{FeatureSound, "Sound Generation", true},
{FeatureRealtime, "Realtime", true},

View File

@@ -584,6 +584,7 @@ func isAPIPath(path string) bool {
strings.HasPrefix(path, "/tts") ||
strings.HasPrefix(path, "/vad") ||
strings.HasPrefix(path, "/video") ||
strings.HasPrefix(path, "/3d/") ||
strings.HasPrefix(path, "/stores/") ||
strings.HasPrefix(path, "/system") ||
strings.HasPrefix(path, "/ws/") ||

View File

@@ -156,6 +156,16 @@ var _ = Describe("Auth Middleware", func() {
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 401 for unauthenticated 3D generation requests", func() {
rec := doRequest(app, http.MethodPost, "/3d/generations")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 401 for unauthenticated 3D remesh requests", func() {
rec := doRequest(app, http.MethodPost, "/3d/remesh")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("allows unauthenticated access to non-API paths when no legacy keys", func() {
rec := doRequest(app, http.MethodGet, "/app")
Expect(rec.Code).To(Equal(http.StatusOK))

View File

@@ -47,6 +47,7 @@ const (
FeatureVAD = "vad"
FeatureDetection = "detection"
FeatureVideo = "video"
Feature3D = "3d"
FeatureEmbeddings = "embeddings"
FeatureSound = "sound"
FeatureRealtime = "realtime"
@@ -73,7 +74,7 @@ var GeneralFeatures = []string{FeatureFineTuning, FeatureQuantization}
var APIFeatures = []string{
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
FeatureAudioDiarization, FeatureAudioClassification,
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
FeaturePIIFilter,

View File

@@ -0,0 +1,66 @@
package http_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Request body limits", func() {
It("lets the remesh route apply its larger limit without weakening other routes", func() {
dir := GinkgoT().TempDir()
models := filepath.Join(dir, "models")
backends := filepath.Join(dir, "backends")
Expect(os.Mkdir(models, 0o750)).To(Succeed())
Expect(os.Mkdir(backends, 0o750)).To(Succeed())
state, err := system.GetSystemState(
system.WithModelPath(models),
system.WithBackendPath(backends),
)
Expect(err).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
localApp, err := application.New(
config.WithContext(ctx),
config.WithSystemState(state),
config.WithUploadLimitMB(1),
)
Expect(err).NotTo(HaveOccurred())
defer func() { _ = localApp.Shutdown() }()
app, err := API(localApp)
Expect(err).NotTo(HaveOccurred())
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
Expect(writer.WriteField("model", "missing-model")).To(Succeed())
part, err := writer.CreateFormFile("mesh", "large.glb")
Expect(err).NotTo(HaveOccurred())
_, err = part.Write(bytes.Repeat([]byte{'x'}, 2<<20))
Expect(err).NotTo(HaveOccurred())
Expect(writer.Close()).To(Succeed())
request := httptest.NewRequest(http.MethodPost, "/3d/remesh", body)
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
app.ServeHTTP(response, request)
Expect(response.Code).To(Equal(http.StatusNotFound), response.Body.String())
request = httptest.NewRequest(http.MethodPost, "/3d/generations", bytes.NewReader(make([]byte, 2<<20)))
request.Header.Set("Content-Type", "application/json")
response = httptest.NewRecorder()
app.ServeHTTP(response, request)
Expect(response.Code).To(Equal(http.StatusRequestEntityTooLarge), response.Body.String())
})
})

View File

@@ -81,6 +81,12 @@ var instructionDefs = []instructionDef{
Tags: []string{"video"},
Intro: "POST /video accepts start_image, end_image, and audio as public URL, base64, or data URI. Backend-specific tuning is passed as string values in params.",
},
{
Name: "3d",
Description: "Image-to-3D asset generation (binary glTF / GLB) via TRELLIS.2",
Tags: []string{"3d"},
Intro: "POST /3d/generations accepts a conditioning image as public URL, base64, or data URI (no text prompt) and returns one .glb asset as a URL under /generated-3d or as b64_json. quality selects the mesh pipeline (auto|coarse|512|1024); background controls solid-background removal (auto|keep|black|white); step, texture_steps, and cfg_scale tune the flow sampling. POST /3d/remesh accepts multipart model, mesh (GLB), and a single detail percentage to return a watertight print-ready GLB; the enclosing offset is derived automatically.",
},
{
Name: "face-recognition",
Description: "Face verification (1:1), identification (1:N), embedding, and demographic analysis",

View File

@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
instructions, ok := resp["instructions"].([]any)
Expect(ok).To(BeTrue())
Expect(instructions).To(HaveLen(17))
Expect(instructions).To(HaveLen(18))
// Verify each instruction has required fields and correct URL format
for _, s := range instructions {
@@ -79,6 +79,7 @@ var _ = Describe("API Instructions Endpoints", func() {
"middleware-admin",
"intelligent-routing",
"voice-library",
"3d",
))
})
})
@@ -123,6 +124,16 @@ var _ = Describe("API Instructions Endpoints", func() {
Expect(string(body)).To(ContainSubstring("stream"))
})
It("should advertise the LocalAI 3D generation path", func() {
req := httptest.NewRequest(http.MethodGet, "/api/instructions/3d", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Body)
Expect(string(body)).To(ContainSubstring("POST /3d/generations"))
Expect(string(body)).NotTo(ContainSubstring("/v1/3d/generations"))
})
It("should return JSON fragment when format=json", func() {
req := httptest.NewRequest(http.MethodGet, "/api/instructions/chat-inference?format=json", nil)
rec := httptest.NewRecorder()

View File

@@ -0,0 +1,186 @@
package localai
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
model "github.com/mudler/LocalAI/pkg/model"
)
// Conditioning images are single frames, so a much tighter cap than the
// video-input limit is enough.
const max3DInputBytes = 32 << 20
var (
valid3DQualities = []string{"", "auto", "coarse", "512", "1024"}
valid3DBackgrounds = []string{"", "auto", "keep", "black", "white"}
)
// Model3DEndpoint
// @Summary Creates a 3D asset (binary glTF / GLB) from a conditioning image.
// @Tags 3d
// @Param request body schema.Model3DRequest true "query params"
// @Success 200 {object} schema.OpenAIResponse "Response"
// @Router /3d/generations [post]
func Model3DEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.Model3DRequest)
if !ok || input.Model == "" {
xlog.Error("3D Endpoint - Invalid Input")
return echo.ErrBadRequest
}
config, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || config == nil {
xlog.Error("3D Endpoint - Invalid Config")
return echo.ErrBadRequest
}
if input.Image == "" {
return echo.NewHTTPError(http.StatusBadRequest, "image is required: 3D generation is image-conditioned")
}
// Reject unknown enum values here rather than surfacing an opaque
// backend error after a model load.
if !slices.Contains(valid3DQualities, input.Quality) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid quality %q: must be one of auto, coarse, 512, 1024", input.Quality))
}
if !slices.Contains(valid3DBackgrounds, input.Background) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid background %q: must be one of auto, keep, black, white", input.Background))
}
src, err := stageVideoMediaWithLimit(c.Request().Context(), appConfig.GeneratedContentDir, input.Image, max3DInputBytes)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid image: %v", err))
}
defer func() { _ = os.Remove(src) }()
xlog.Debug("Parameter Config", "config", config)
if config.Backend == "" {
config.Backend = model.Trellis2CppBackend
}
step := input.Step
if step == 0 && config.Step != 0 {
step = int32(config.Step)
}
cfgScale := input.CFGScale
if cfgScale == 0 && config.CFGScale != 0 {
cfgScale = config.CFGScale
}
b64JSON := input.ResponseFormat == "b64_json"
tempDir := ""
if !b64JSON {
tempDir = filepath.Join(appConfig.GeneratedContentDir, "3d")
if err := os.MkdirAll(tempDir, 0o750); err != nil {
return err
}
}
// Create a temporary file
outputFile, err := os.CreateTemp(tempDir, "b64")
if err != nil {
return err
}
if err := outputFile.Close(); err != nil {
_ = os.Remove(outputFile.Name())
return err
}
output := outputFile.Name() + ".glb"
// Rename the temporary file
err = os.Rename(outputFile.Name(), output)
if err != nil {
_ = os.Remove(outputFile.Name())
return err
}
preserveOutput := false
defer func() {
if !preserveOutput {
_ = os.Remove(output)
}
}()
baseURL := middleware.BaseURL(c)
xlog.Debug("Model3DEndpoint: Calling Model3DGeneration",
"quality", input.Quality,
"background", input.Background,
"cfg_scale", cfgScale,
"step", step,
"texture_steps", input.TextureSteps,
"seed", input.Seed)
fn, err := backend.Model3DGeneration(
backend.Model3DGenerationOptions{
Image: src,
Destination: output,
Seed: input.Seed,
Step: step,
CFGScale: cfgScale,
TextureSteps: input.TextureSteps,
Quality: input.Quality,
Background: input.Background,
Params: input.Params,
},
ml,
*config,
appConfig,
)
if err != nil {
return mapBackendError(err)
}
if err := fn(); err != nil {
return mapBackendError(err)
}
item := &schema.Item{}
if b64JSON {
data, err := os.ReadFile(output)
if err != nil {
return err
}
item.B64JSON = base64.StdEncoding.EncodeToString(data)
} else {
base := filepath.Base(output)
item.URL, err = url.JoinPath(baseURL, "generated-3d", base)
if err != nil {
return err
}
preserveOutput = true
}
id := uuid.New().String()
created := int(time.Now().Unix())
resp := &schema.OpenAIResponse{
ID: id,
Created: created,
Data: []schema.Item{*item},
}
jsonResult, _ := json.Marshal(resp)
xlog.Debug("Response", "response", string(jsonResult))
return c.JSON(200, resp)
}
}

View File

@@ -0,0 +1,72 @@
package localai
import (
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
)
// The validation branches all return before any model load, so the handler can
// be driven with nil loaders.
var _ = Describe("3D endpoint request validation", func() {
call := func(input *schema.Model3DRequest) error {
appConfig := &config.ApplicationConfig{GeneratedContentDir: GinkgoT().TempDir()}
handler := Model3DEndpoint(nil, nil, appConfig)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/3d/generations", strings.NewReader("{}"))
c := e.NewContext(req, httptest.NewRecorder())
c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "test-3d"})
return handler(c)
}
expectBadRequest := func(err error, substr string) {
var httpErr *echo.HTTPError
Expect(err).To(BeAssignableToTypeOf(httpErr))
httpErr = err.(*echo.HTTPError)
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
Expect(httpErr.Message).To(ContainSubstring(substr))
}
It("requires a conditioning image", func() {
err := call(&schema.Model3DRequest{BasicModelRequest: schema.BasicModelRequest{Model: "m"}})
expectBadRequest(err, "image is required")
})
It("rejects unknown quality values", func() {
err := call(&schema.Model3DRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "m"},
Image: "aGk=",
Quality: "2048",
})
expectBadRequest(err, "invalid quality")
})
It("rejects unknown background values", func() {
err := call(&schema.Model3DRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "m"},
Image: "aGk=",
Background: "transparent",
})
expectBadRequest(err, "invalid background")
})
It("rejects undecodable image payloads", func() {
err := call(&schema.Model3DRequest{
BasicModelRequest: schema.BasicModelRequest{Model: "m"},
Image: "not%%%base64",
Quality: "512",
Background: "auto",
})
expectBadRequest(err, "invalid image")
})
})

View File

@@ -0,0 +1,163 @@
package localai
import (
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
)
const (
max3DRemeshBytes = 512 << 20
defaultRemeshDetailPct = float32(0.5)
minRemeshDetailPct = float32(0.35)
maxRemeshDetailPct = float32(2.5)
)
func normalizedRemeshDetail(detail float32) (float32, error) {
if detail == 0 {
return defaultRemeshDetailPct, nil
}
if math.IsNaN(float64(detail)) || math.IsInf(float64(detail), 0) || detail < minRemeshDetailPct || detail > maxRemeshDetailPct {
return 0, fmt.Errorf("detail must be between %.2f and %.2f percent", minRemeshDetailPct, maxRemeshDetailPct)
}
return detail, nil
}
func saveRemeshUpload(c echo.Context, dir string) (string, error) {
header, err := c.FormFile("mesh")
if err != nil {
return "", fmt.Errorf("mesh is required")
}
if header.Size > max3DRemeshBytes {
return "", fmt.Errorf("mesh exceeds the 512 MiB limit")
}
source, err := header.Open()
if err != nil {
return "", fmt.Errorf("opening mesh: %w", err)
}
defer func() { _ = source.Close() }()
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
temp, err := os.CreateTemp(dir, "remesh-input-*.glb")
if err != nil {
return "", err
}
path := temp.Name()
defer func() {
if err != nil {
_ = os.Remove(path)
}
}()
written, copyErr := io.Copy(temp, io.LimitReader(source, max3DRemeshBytes+1))
closeErr := temp.Close()
if copyErr != nil {
err = fmt.Errorf("saving mesh: %w", copyErr)
return "", err
}
if closeErr != nil {
err = closeErr
return "", err
}
if written == 0 {
err = fmt.Errorf("mesh is empty")
return "", err
}
if written > max3DRemeshBytes {
err = fmt.Errorf("mesh exceeds the 512 MiB limit")
return "", err
}
return path, nil
}
// Model3DRemeshEndpoint rebuilds an existing generated GLB as a watertight mesh.
// @Summary Applies watertight print remeshing to an existing 3D asset.
// @Tags 3d
// @Accept multipart/form-data
// @Produce model/gltf-binary
// @Param model formData string true "3D model name"
// @Param mesh formData file true "Source GLB"
// @Param detail formData number false "Detail size as percent of the source bounding-box diagonal (0.352.5; default 0.5)"
// @Success 200 {file} binary "Remeshed GLB"
// @Router /3d/remesh [post]
func Model3DRemeshEndpoint(ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.Model3DRemeshRequest)
if !ok || input.Model == "" {
return echo.NewHTTPError(http.StatusBadRequest, "model is required")
}
modelConfig, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || modelConfig == nil {
return echo.ErrBadRequest
}
detail, err := normalizedRemeshDetail(input.Detail)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
source, err := saveRemeshUpload(c, appConfig.GeneratedContentDir)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
defer func() { _ = os.Remove(source) }()
outputFile, err := os.CreateTemp(appConfig.GeneratedContentDir, "remeshed-*.glb")
if err != nil {
return err
}
output := outputFile.Name()
if err := outputFile.Close(); err != nil {
_ = os.Remove(output)
return err
}
defer func() { _ = os.Remove(output) }()
if modelConfig.Backend == "" {
modelConfig.Backend = model.Trellis2CppBackend
}
fn, err := backend.Model3DGeneration(
backend.Model3DGenerationOptions{
Image: source,
Destination: output,
Params: map[string]string{
"operation": "print_remesh",
"alpha_ratio": strconv.FormatFloat(float64(detail/100), 'g', -1, 32),
"detail_percent": strconv.FormatFloat(float64(detail), 'g', -1, 32),
"texture_size": "2048",
},
},
ml,
*modelConfig,
appConfig,
)
if err != nil {
return mapBackendError(err)
}
if err := fn(); err != nil {
return mapBackendError(err)
}
file, err := os.Open(filepath.Clean(output))
if err != nil {
return err
}
defer func() { _ = file.Close() }()
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="remeshed.glb"`)
c.Response().Header().Set(echo.HeaderCacheControl, "no-store")
return c.Stream(http.StatusOK, "model/gltf-binary", file)
}
}

View File

@@ -0,0 +1,68 @@
package localai
import (
"bytes"
"math"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("3D print remeshing request handling", func() {
DescribeTable("normalizes the demo detail range",
func(input, expected float32, valid bool) {
value, err := normalizedRemeshDetail(input)
if valid {
Expect(err).NotTo(HaveOccurred())
Expect(value).To(BeNumerically("~", expected, 1e-6))
} else {
Expect(err).To(HaveOccurred())
}
},
Entry("default", float32(0), float32(0.5), true),
Entry("fine endpoint", float32(0.35), float32(0.35), true),
Entry("coarse endpoint", float32(2.5), float32(2.5), true),
Entry("too fine", float32(0.1), float32(0), false),
Entry("too coarse", float32(3), float32(0), false),
Entry("not a number", float32(math.NaN()), float32(0), false),
)
It("streams the multipart GLB to a bounded temporary file", func() {
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
Expect(writer.WriteField("model", "trellis-test-model")).To(Succeed())
Expect(writer.WriteField("detail", "0.35")).To(Succeed())
part, err := writer.CreateFormFile("mesh", "source.glb")
Expect(err).NotTo(HaveOccurred())
_, err = part.Write([]byte("glTF-test"))
Expect(err).NotTo(HaveOccurred())
Expect(writer.Close()).To(Succeed())
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/3d/remesh", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
ctx := e.NewContext(req, httptest.NewRecorder())
input := new(schema.Model3DRemeshRequest)
Expect(ctx.Bind(input)).To(Succeed())
Expect(input.Model).To(Equal("trellis-test-model"))
Expect(input.Detail).To(BeNumerically("~", 0.35, 1e-6))
path, err := saveRemeshUpload(ctx, GinkgoT().TempDir())
Expect(err).NotTo(HaveOccurred())
defer func() { _ = os.Remove(path) }()
Expect(os.ReadFile(path)).To(Equal([]byte("glTF-test")))
})
It("requires a mesh part", func() {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/3d/remesh", bytes.NewReader(nil))
ctx := e.NewContext(req, httptest.NewRecorder())
_, err := saveRemeshUpload(ctx, GinkgoT().TempDir())
Expect(err).To(MatchError("mesh is required"))
})
})

View File

@@ -14,6 +14,7 @@ import { test, expect } from './coverage-fixtures.js'
// not bounce to a gate redirect (/login or back to /app home).
const PAGES = [
['/app/talk', 'Talk'],
['/app/3d', '3D Generation'],
['/app/usage', 'Usage'],
['/app/account', 'Account'],
['/app/studio', 'Studio'],

View File

@@ -0,0 +1,319 @@
import { test, expect } from './coverage-fixtures.js'
// 3D generation page: mock the capabilities + generation endpoints, feed a
// real (tiny) Form-B GLB through the parser/viewer, and exercise the
// IndexedDB-backed history. All assertions are DOM/text — never pixels — so
// the suite passes with or without working WebGL2 in headless Chromium.
function mockCapabilities(page) {
return page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 'trellis-test-model', capabilities: ['FLAG_3D'] }] }),
})
})
}
// A valid one-triangle GLB in trellis2cpp's vertex-PBR form: POSITION/NORMAL
// f32, COLOR_0 u16-normalized VEC4, _METALLIC_ROUGHNESS u8-normalized VEC2,
// u32 indices — the layout mesh_export.cpp's write_vertex_glb emits.
function buildTinyGlb() {
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])
const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1])
const colors = new Uint16Array([
65535, 0, 0, 65535,
0, 65535, 0, 65535,
0, 0, 65535, 65535,
])
const metalRough = new Uint8Array([0, 153, 0, 153, 0, 153])
const indices = new Uint32Array([0, 1, 2])
const views = []
let binLength = 0
const addView = (typed) => {
const byteOffset = binLength
views.push({ buffer: 0, byteOffset, byteLength: typed.byteLength, target: 34962 })
binLength += typed.byteLength
binLength += (4 - (binLength % 4)) % 4
return views.length - 1
}
addView(positions); addView(normals); addView(colors); addView(metalRough)
const idxView = addView(indices)
views[idxView].target = 34963
const json = {
asset: { version: '2.0', generator: 'threed-gen.spec' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0, NORMAL: 1, COLOR_0: 2, _METALLIC_ROUGHNESS: 3 }, indices: 4, material: 0 }] }],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 1], metallicFactor: 0, roughnessFactor: 0.6 }, doubleSided: true }],
accessors: [
{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3', min: [0, 0, 0], max: [1, 1, 0] },
{ bufferView: 1, componentType: 5126, count: 3, type: 'VEC3' },
{ bufferView: 2, componentType: 5123, normalized: true, count: 3, type: 'VEC4' },
{ bufferView: 3, componentType: 5121, normalized: true, count: 3, type: 'VEC2' },
{ bufferView: 4, componentType: 5125, count: 3, type: 'SCALAR' },
],
bufferViews: views,
buffers: [{ byteLength: binLength }],
}
const bin = Buffer.alloc(binLength)
const parts = [positions, normals, colors, metalRough, indices]
for (let i = 0; i < parts.length; i++) {
Buffer.from(parts[i].buffer, parts[i].byteOffset, parts[i].byteLength).copy(bin, views[i].byteOffset)
}
let jsonText = JSON.stringify(json)
while (jsonText.length % 4 !== 0) jsonText += ' '
const jsonBuf = Buffer.from(jsonText)
const total = 12 + 8 + jsonBuf.length + 8 + bin.length
const glb = Buffer.alloc(total)
glb.writeUInt32LE(0x46546c67, 0) // magic 'glTF'
glb.writeUInt32LE(2, 4)
glb.writeUInt32LE(total, 8)
glb.writeUInt32LE(jsonBuf.length, 12)
glb.writeUInt32LE(0x4e4f534a, 16) // 'JSON'
jsonBuf.copy(glb, 20)
const binHeader = 20 + jsonBuf.length
glb.writeUInt32LE(bin.length, binHeader)
glb.writeUInt32LE(0x004e4942, binHeader + 4) // 'BIN\0'
bin.copy(glb, binHeader + 8)
return glb
}
// 1x1 transparent PNG for the conditioning-image upload.
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
function mockGeneration(page, onRequest) {
return page.route('**/3d/generations', (route) => {
if (route.request().method() !== 'POST') return route.continue()
onRequest?.(route.request().postDataJSON())
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
created: Math.floor(Date.now() / 1000),
data: [{ url: '/generated-3d/test.glb' }],
}),
})
})
}
function mockGlbDownload(page) {
return page.route('**/generated-3d/test.glb', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'model/gltf-binary' },
body: buildTinyGlb(),
})
})
}
async function generateOnce(page) {
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({
name: 'input.png',
mimeType: 'image/png',
buffer: TINY_PNG,
})
await page.locator('button[type="submit"]').click()
}
test.describe('3D generation', () => {
test.beforeEach(async ({ page }) => {
await mockCapabilities(page)
await mockGlbDownload(page)
})
test('generates, shows mesh stats, and offers a GLB download', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
await generateOnce(page)
// Stats render from the parsed GLB even without working GL.
await expect(page.getByTestId('glb-stats')).toContainText('3', { timeout: 15_000 })
await expect(page.getByTestId('glb-stats')).toContainText('1')
await expect(page.getByTestId('glb-stats')).toContainText('PBR')
const download = page.getByTestId('glb-download')
await expect(download).toBeVisible()
await expect(download).toHaveAttribute('href', /^blob:/)
await expect(download).toHaveAttribute('download', /\.glb$/)
expect(requestBody.model).toBe('trellis-test-model')
expect(requestBody.image).toBeTruthy()
expect(requestBody.quality).toBe('auto')
expect(requestBody.background).toBe('auto')
expect(requestBody.response_format).toBe('url')
})
test('advanced settings map to step/texture_steps/cfg_scale/seed', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG })
await page.locator('select').first().selectOption('512')
await page.getByRole('button', { name: /Advanced Settings/ }).click()
const advanced = page.locator('#threed-advanced-options')
await advanced.locator('input').nth(0).fill('20') // steps
await advanced.locator('input').nth(1).fill('8') // texture steps
await advanced.locator('input').nth(2).fill('5.5') // guidance
await advanced.locator('input').nth(3).fill('42') // seed
await page.locator('button[type="submit"]').click()
await expect(page.getByTestId('glb-download')).toBeVisible({ timeout: 15_000 })
expect(requestBody.quality).toBe('512')
expect(requestBody.step).toBe(20)
expect(requestBody.texture_steps).toBe(8)
expect(requestBody.cfg_scale).toBe(5.5)
expect(requestBody.seed).toBe(42)
})
test('applies a single-detail watertight remesh and previews it before download', async ({ page }) => {
await mockGeneration(page)
let remeshRequest = null
await page.route('**/3d/remesh', (route) => {
remeshRequest = route.request()
route.fulfill({
status: 200,
headers: { 'Content-Type': 'model/gltf-binary' },
body: buildTinyGlb(),
})
})
await generateOnce(page)
await expect(page.getByTestId('glb-remesh')).toBeVisible({ timeout: 15_000 })
await page.getByLabel('Remesh detail').fill('100')
await page.getByTestId('glb-remesh').click()
await expect(page.getByTestId('glb-remesh')).toContainText('Show original')
await expect(page.getByTestId('glb-download')).toHaveAttribute('download', /-remeshed\.glb$/)
expect(remeshRequest).not.toBeNull()
expect(remeshRequest.method()).toBe('POST')
expect(remeshRequest.headers()['content-type']).toContain('multipart/form-data')
const multipart = remeshRequest.postDataBuffer().toString()
expect(multipart).toContain('trellis-test-model')
expect(multipart).toContain('0.35')
await page.getByTestId('glb-remesh').click()
await expect(page.getByTestId('glb-remesh')).toContainText('Apply remeshing')
await expect(page.getByTestId('glb-download')).not.toHaveAttribute('download', /-remeshed\.glb$/)
})
test('history entry persists across navigation and reloads into the viewer', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
// IndexedDB persists within the browser context — navigate away and back.
await page.goto('/app')
await page.goto('/app/3d')
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
// Selecting the entry loads the stored Blob back into the viewer.
await page.getByTestId('media-history-item').click()
await expect(page.getByTestId('glb-stats')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('glb-download')).toHaveAttribute('href', /^blob:/)
})
test('deleting a history entry removes it', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
await page.getByTestId('media-history-delete').click()
await expect(page.getByTestId('media-history-item')).toHaveCount(0)
})
test('API errors surface through the trace link error box', async ({ page }) => {
await page.route('**/3d/generations', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'trellis2 pipeline load: missing files' } }),
})
})
await generateOnce(page)
await expect(page.locator('.media-result')).toContainText(/missing files|error/i, { timeout: 15_000 })
})
test('rejects oversized conditioning images before making an API request', async ({ page }) => {
let requested = false
await mockGeneration(page, () => { requested = true })
await page.goto('/app/3d')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({
name: 'oversized.png',
mimeType: 'image/png',
buffer: Buffer.alloc(32 * 1024 * 1024 + 1),
})
await expect(page.locator('.toast')).toContainText('32 MiB limit')
expect(requested).toBe(false)
})
test('hides and guards 3D generation when the feature is disabled', async ({ page }) => {
await page.route('**/api/auth/status', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
authEnabled: true,
staticApiKeyRequired: false,
user: { id: 'restricted-user', role: 'user', permissions: { '3d': false } },
}),
})
})
await page.goto('/app/studio?tab=threed')
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/)
await page.goto('/app/3d')
await expect(page).toHaveURL(/\/app\/?$/)
})
test.describe('touch input', () => {
test.use({ hasTouch: true })
test('accepts two-finger touch gestures in the GLB viewer', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
const canvas = page.getByTestId('glb-canvas')
await expect(canvas).toBeVisible({ timeout: 15_000 })
await canvas.scrollIntoViewIfNeeded()
const box = await canvas.boundingBox()
expect(box).not.toBeNull()
const client = await page.context().newCDPSession(page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ id: 1, x: box.x + box.width * 0.35, y: box.y + box.height * 0.5 },
{ id: 2, x: box.x + box.width * 0.65, y: box.y + box.height * 0.5 },
],
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ id: 1, x: box.x + box.width * 0.3, y: box.y + box.height * 0.45 },
{ id: 2, x: box.x + box.width * 0.7, y: box.y + box.height * 0.55 },
],
})
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await expect(page.getByRole('button', { name: 'Auto-rotate' })).toHaveClass(/btn-secondary/)
})
})
})

View File

@@ -5,7 +5,8 @@
"video": "Video",
"tts": "TTS",
"sound": "Sound",
"transform": "Transform"
"transform": "Transform",
"threed": "3D"
}
},
"image": {
@@ -70,6 +71,59 @@
"noResults": "No video generated"
}
},
"threed": {
"title": "3D Generation",
"labels": {
"model": "Model",
"image": "Input image",
"quality": "Quality",
"quality_auto": "Auto (best available)",
"quality_coarse": "Coarse preview (fast)",
"quality_512": "512³ fine",
"quality_1024": "1024³ cascade (slow)",
"background": "Background",
"background_auto": "Auto-remove solid background",
"background_keep": "Keep original",
"background_black": "Remove black",
"background_white": "Remove white",
"advanced": "Advanced Settings",
"steps": "Shape steps",
"textureSteps": "Material steps",
"guidance": "Guidance",
"seed": "Seed",
"seedPlaceholder": "Random"
},
"actions": {
"generate": "Generate",
"generating": "Generating...",
"remesh": "Apply remeshing",
"remeshing": "Applying remeshing...",
"showOriginal": "Show original",
"download": "Download GLB"
},
"remesh": {
"title": "Watertight print remesh",
"detail": "Remesh detail",
"coarser": "Coarser · faster",
"finer": "Finer · slower",
"hint": "Detail controls the smallest preserved features. The enclosing offset follows it automatically.",
"ready": "Previewing the watertight remeshed model. This is the version that will be downloaded."
},
"viewer": {
"wireframe": "Wireframe",
"autoRotate": "Auto-rotate",
"noWebgl": "WebGL2 is not available in this browser — download the GLB to view it elsewhere.",
"contextLost": "The 3D view lost the GPU context — reload the page to restore it.",
"stats": "{{verts}} vertices · {{tris}} triangles",
"hint": "drag: rotate · pinch/wheel: zoom · two-finger/right-drag: pan · double-click: reset"
},
"empty": "Generated 3D model will appear here",
"toasts": {
"noImage": "Please provide an input image",
"noModel": "Please select a model",
"noResults": "No model generated"
}
},
"tts": {
"title": "Text to Speech",
"labels": {

View File

@@ -29,6 +29,7 @@
"llm": "Chat",
"image": "Image",
"video": "Video",
"threed": "3D",
"multimodal": "Multimodal",
"vision": "Vision",
"tts": "TTS",

View File

@@ -5166,6 +5166,45 @@ button.collapsible-header:focus-visible {
border-radius: var(--radius-md);
}
.threed-remesh-controls {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-md);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.threed-remesh-heading,
.threed-remesh-scale {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
}
.threed-remesh-heading {
color: var(--color-text-primary);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
}
.threed-remesh-heading output,
.threed-remesh-scale,
.threed-remesh-ready {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.threed-remesh-controls input[type="range"] {
width: 100%;
}
.threed-remesh-ready {
margin: var(--spacing-xs) 0 0;
color: var(--color-success);
}
.media-result-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -0,0 +1,636 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { parseGlb } from '../utils/glb'
/* ── WebGL2 GLB viewer ──────────────────────────────────────────────────────
* Ported from the trellis2cpp demo server's hand-rolled viewer
* (localai-org/trellis2cpp server/web/index.html): quaternion trackball,
* metallic-roughness PBR with a procedural environment, ACES tonemapping,
* hidden-line wireframe. Kept dependency-free — the renderer is tuned for
* the dense unoriented dual-grid meshes TRELLIS.2 produces, which generic
* GLTF viewers shade poorly.
*
* Deltas from the demo: input is a parsed GLB (see utils/glb.js) instead of
* the demo's T2MESH stream; COLOR_0 arrives LINEAR (no gamma decode — the
* demo's pow(2.2) would double-darken it); vertex attributes upload as
* normalized integers straight from the GLB buffers; an optional UV-texture
* path covers the atlas-baked form; the base orientation drops the demo's
* Z-up -> Y-up turn because baked GLBs are already Y-up.
*/
const VS = `#version 300 es
layout(location=0) in vec3 pos;
layout(location=1) in vec3 nrm;
layout(location=2) in vec4 baseColor;
layout(location=3) in vec2 metalRough;
layout(location=4) in vec2 uv;
uniform mat4 mvp, viewModel;
out vec3 vN; out vec3 vV; out vec4 vCol; out vec2 vMR; out vec2 vUV;
void main() {
gl_Position = mvp * vec4(pos, 1.0);
vec4 vp = viewModel * vec4(pos, 1.0);
vV = -vp.xyz; // view-space: to-camera (camera at origin)
vN = mat3(viewModel) * nrm; // view-space normal (orbits with camera)
vCol = baseColor;
vMR = metalRough;
vUV = uv;
}`
// Lightweight metallic-roughness PBR in view space. The dual-grid mesh has
// unoriented winding (faithful to TRELLIS.2), so every normal is faced toward
// the camera. No IBL — a procedural environment + one studio key light;
// ACES tonemapping at the end.
const FS = `#version 300 es
precision highp float;
in vec3 vN; in vec3 vV; in vec4 vCol; in vec2 vMR; in vec2 vUV;
uniform int wire;
uniform int colorMode; // 0 = untextured grey, 1 = vertex PBR (linear COLOR_0), 2 = UV textures
uniform sampler2D baseColorTex;
uniform sampler2D mrTex;
uniform float uMetallicFactor, uRoughnessFactor;
out vec4 frag;
// procedural environment radiance in a direction (view space; +y = up)
vec3 envColor(vec3 d) {
vec3 sky = mix(vec3(0.32, 0.40, 0.55), vec3(0.72, 0.80, 0.98), clamp(d.y, 0.0, 1.0));
vec3 gnd = vec3(0.14, 0.13, 0.12);
return mix(gnd, sky, smoothstep(-0.30, 0.12, d.y));
}
void main() {
if (wire == 1) { frag = vec4(0.30, 0.64, 1.00, 1.0); return; }
vec3 N = normalize(vN), V = normalize(vV);
if (dot(N, V) < 0.0) N = -N; // face the camera (mesh is unoriented)
vec3 base = vec3(0.62, 0.66, 0.72);
float metal = 0.0, rough = 0.5, opacity = 1.0;
if (colorMode == 1) {
// COLOR_0 is stored linear in the GLB — use it directly.
base = clamp(vCol.rgb, 0.0, 1.0);
metal = clamp(vMR.x, 0.0, 1.0);
rough = clamp(vMR.y, 0.06, 1.0);
opacity = clamp(vCol.a, 0.0, 1.0);
} else if (colorMode == 2) {
vec4 bc = texture(baseColorTex, vUV);
base = pow(clamp(bc.rgb, 0.0, 1.0), vec3(2.2)); // baseColorTexture is sRGB
opacity = bc.a;
vec3 mr = texture(mrTex, vUV).rgb; // G=roughness, B=metallic
metal = clamp(mr.b * uMetallicFactor, 0.0, 1.0);
rough = clamp(mr.g * uRoughnessFactor, 0.06, 1.0);
}
if (opacity < 0.01) discard;
float nv = max(dot(N, V), 1e-3);
// Schlick Fresnel (grazing reflectance rises to 1 - roughness for metals).
vec3 F0 = mix(vec3(0.04), base, metal);
vec3 F = F0 + (max(vec3(1.0 - rough), F0) - F0) * pow(1.0 - nv, 5.0);
// diffuse: hemispheric environment irradiance (metals have no diffuse)
vec3 irr = envColor(N) * 0.55 + vec3(0.12);
vec3 diffuse = base * (1.0 - metal) * irr;
// specular: environment reflection, blurred toward a flat tint by roughness
vec3 refl = mix(envColor(reflect(-V, N)), vec3(0.34, 0.37, 0.44), rough * rough);
vec3 specular = refl * F;
// one crisp studio key light for a lively highlight
vec3 L = normalize(vec3(0.45, 0.70, 0.55)), H = normalize(L + V);
float shin = mix(8.0, 260.0, pow(1.0 - rough, 2.0));
float sp = pow(max(dot(N, H), 0.0), shin) * (shin + 2.0) / 6.2831853;
vec3 kc = vec3(1.00, 0.96, 0.88);
float ndl = max(dot(N, L), 0.0);
specular += kc * sp * F * ndl;
diffuse += base * (1.0 - metal) * kc * ndl * 0.28;
vec3 color = diffuse + specular;
color = (color * (2.51 * color + 0.03)) / (color * (2.43 * color + 0.59) + 0.14); // ACES
frag = vec4(pow(clamp(color, 0.0, 1.0), vec3(1.0/2.2)), opacity);
}`
/* trackball orientation (quaternion): each drag composes a small rotation
* about the screen axes onto the current orientation — no gimbal lock. */
const Q = {
axisAngle(x, y, z, a) { const h = a * 0.5, s = Math.sin(h); return [x * s, y * s, z * s, Math.cos(h)] },
mul(a, b) { // Hamilton product a·b (apply b, then a)
return [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
]
},
norm(q) { const n = Math.hypot(q[0], q[1], q[2], q[3]) || 1; return [q[0] / n, q[1] / n, q[2] / n, q[3] / n] },
toMat4(q) { // column-major rotation matrix
const [x, y, z, w] = q
const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z
return new Float32Array([
1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
0, 0, 0, 1,
])
},
}
// GLBs are already Y-up (the baker swaps axes on export), so unlike the demo
// there is no Z-up correction here — just a gentle 3/4 default view.
const QBASE = Q.norm(Q.mul(Q.axisAngle(1, 0, 0, -0.30), Q.axisAngle(0, 1, 0, 0.55)))
/* minimal mat4 helpers (column-major) */
const M = {
mul(a, b) {
const o = new Float32Array(16)
for (let c = 0; c < 4; ++c) for (let r = 0; r < 4; ++r)
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
return o
},
persp(fov, asp, near, far) {
const f = 1 / Math.tan(fov / 2), o = new Float32Array(16)
o[0] = f / asp; o[5] = f
o[10] = (far + near) / (near - far); o[11] = -1
o[14] = 2 * far * near / (near - far)
return o
},
trans(x, y, z) {
const o = new Float32Array(16)
o[0] = o[5] = o[10] = o[15] = 1
o[12] = x; o[13] = y; o[14] = z
return o
},
scale(s) {
const o = new Float32Array(16)
o[0] = o[5] = o[10] = s; o[15] = 1
return o
},
}
function glType(gl, array) {
if (array instanceof Uint8Array) return gl.UNSIGNED_BYTE
if (array instanceof Uint16Array) return gl.UNSIGNED_SHORT
return gl.FLOAT
}
export function createGlbViewer(canvas, { onContextLost } = {}) {
const gl = canvas.getContext('webgl2', { antialias: true })
if (!gl) return null
// If the GPU context is lost (driver reset / out of memory), preventDefault
// keeps it recoverable and the page shows a notice instead of a dead canvas.
const contextLost = (e) => {
e.preventDefault()
if (onContextLost) onContextLost()
}
canvas.addEventListener('webglcontextlost', contextLost, false)
function shader(type, src) {
const s = gl.createShader(type)
gl.shaderSource(s, src); gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s))
return s
}
const prog = gl.createProgram()
gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS))
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS))
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog))
const uMVP = gl.getUniformLocation(prog, 'mvp')
const uViewModel = gl.getUniformLocation(prog, 'viewModel')
const uWire = gl.getUniformLocation(prog, 'wire')
const uColorMode = gl.getUniformLocation(prog, 'colorMode')
const uBaseColorTex = gl.getUniformLocation(prog, 'baseColorTex')
const uMrTex = gl.getUniformLocation(prog, 'mrTex')
const uMetallicFactor = gl.getUniformLocation(prog, 'uMetallicFactor')
const uRoughnessFactor = gl.getUniformLocation(prog, 'uRoughnessFactor')
const vao = gl.createVertexArray()
const vbo = gl.createBuffer(), nbo = gl.createBuffer(), cbo = gl.createBuffer()
const mbo = gl.createBuffer(), ubo = gl.createBuffer(), ibo = gl.createBuffer()
const wireIbo = gl.createBuffer()
let nIndices = 0, nWire = 0
let colorMode = 0
let baseColorTexture = null, mrTexture = null
let metallicFactor = 1, roughnessFactor = 1
// fit-to-view: model = rotation * scale * translate(-center) keeps the demo's
// camera constants valid for any GLB extent (trellis meshes are ~unit cube).
let center = [0, 0, 0], fitScale = 1
let rot = QBASE.slice(), dist = 1.8, panX = 0, panY = 0
let wire = false, spin = true
let disposed = false
function makeTexture(bitmap) {
const tex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
// Matches the baker's sampler: linear filtering, clamp, no mipmaps.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap)
gl.bindTexture(gl.TEXTURE_2D, null)
return tex
}
function dropTextures() {
if (baseColorTexture) { gl.deleteTexture(baseColorTexture); baseColorTexture = null }
if (mrTexture) { gl.deleteTexture(mrTexture); mrTexture = null }
}
async function setMesh(mesh) {
gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, vbo)
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0)
if (mesh.normals) {
gl.bindBuffer(gl.ARRAY_BUFFER, nbo)
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW)
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0)
} else {
gl.disableVertexAttribArray(1)
gl.vertexAttrib3f(1, 0, 1, 0)
}
dropTextures()
colorMode = 0
if (mesh.color0) {
// Upload COLOR_0 / _METALLIC_ROUGHNESS as normalized integers straight
// from the GLB buffers — no CPU conversion of multi-million-vertex data.
gl.bindBuffer(gl.ARRAY_BUFFER, cbo)
gl.bufferData(gl.ARRAY_BUFFER, mesh.color0.array, gl.STATIC_DRAW)
gl.enableVertexAttribArray(2)
gl.vertexAttribPointer(2, mesh.color0.size, glType(gl, mesh.color0.array), mesh.color0.normalized, 0, 0)
if (mesh.metalRough) {
gl.bindBuffer(gl.ARRAY_BUFFER, mbo)
gl.bufferData(gl.ARRAY_BUFFER, mesh.metalRough.array, gl.STATIC_DRAW)
gl.enableVertexAttribArray(3)
gl.vertexAttribPointer(3, mesh.metalRough.size, glType(gl, mesh.metalRough.array), mesh.metalRough.normalized, 0, 0)
} else {
gl.disableVertexAttribArray(3)
gl.vertexAttrib2f(3, 0, 0.6)
}
gl.disableVertexAttribArray(4)
colorMode = 1
} else if (mesh.uv && mesh.baseColorPng) {
gl.bindBuffer(gl.ARRAY_BUFFER, ubo)
gl.bufferData(gl.ARRAY_BUFFER, mesh.uv, gl.STATIC_DRAW)
gl.enableVertexAttribArray(4)
gl.vertexAttribPointer(4, 2, gl.FLOAT, false, 0, 0)
gl.disableVertexAttribArray(2)
gl.disableVertexAttribArray(3)
const bitmaps = await Promise.all([
createImageBitmap(new Blob([mesh.baseColorPng], { type: 'image/png' })),
mesh.metalRoughPng ? createImageBitmap(new Blob([mesh.metalRoughPng], { type: 'image/png' })) : null,
])
if (disposed) return
gl.bindVertexArray(vao)
baseColorTexture = makeTexture(bitmaps[0])
mrTexture = bitmaps[1] ? makeTexture(bitmaps[1]) : makeTexture(bitmaps[0])
metallicFactor = mesh.material.metallicFactor
roughnessFactor = mesh.material.roughnessFactor
colorMode = 2
} else {
gl.disableVertexAttribArray(2)
gl.disableVertexAttribArray(3)
gl.disableVertexAttribArray(4)
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW)
nIndices = mesh.indices.length
// wireframe index buffer: 3 edges per triangle, bounded to WIRE_BUDGET —
// a full wireframe of a multi-million-triangle mesh would OOM the GPU and
// lose the context (sub-pixel lines also fill in as a solid mass).
const WIRE_BUDGET = 12_000_000 // ~6M segments, ~48 MB
const nTri = nIndices / 3
const wireStride = Math.max(1, Math.ceil(nTri * 6 / WIRE_BUDGET))
const wireIdx = new Uint32Array(Math.ceil(nTri / wireStride) * 6)
let o = 0
const idx = mesh.indices
for (let t = 0; t < nIndices; t += 3 * wireStride) {
wireIdx[o++] = idx[t]; wireIdx[o++] = idx[t + 1]
wireIdx[o++] = idx[t + 1]; wireIdx[o++] = idx[t + 2]
wireIdx[o++] = idx[t + 2]; wireIdx[o++] = idx[t]
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, wireIbo)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wireIdx.subarray(0, o), gl.STATIC_DRAW)
nWire = o
gl.bindVertexArray(null)
center = [
(mesh.bboxMin[0] + mesh.bboxMax[0]) / 2,
(mesh.bboxMin[1] + mesh.bboxMax[1]) / 2,
(mesh.bboxMin[2] + mesh.bboxMax[2]) / 2,
]
const radius = Math.hypot(
mesh.bboxMax[0] - mesh.bboxMin[0],
mesh.bboxMax[1] - mesh.bboxMin[1],
mesh.bboxMax[2] - mesh.bboxMin[2],
) / 2 || 1
fitScale = 0.866 / radius
resetView()
}
function clear() {
nIndices = 0
nWire = 0
dropTextures()
}
function resetView() {
rot = QBASE.slice(); dist = 1.8; panX = panY = 0
}
/* input */
let dragging = false, panning = false, lx = 0, ly = 0
let pinchDistance = 0, pinchX = 0, pinchY = 0
const pointers = new Map()
const pointerPair = () => Array.from(pointers.values()).slice(0, 2)
const beginPinch = () => {
const [a, b] = pointerPair()
if (!a || !b) return
pinchDistance = Math.hypot(b.x - a.x, b.y - a.y)
pinchX = (a.x + b.x) / 2
pinchY = (a.y + b.y) / 2
}
const stopSpin = () => {
spin = false
if (onSpinChange) onSpinChange(false)
}
const onPointerDown = (e) => {
e.preventDefault()
canvas.setPointerCapture(e.pointerId)
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.size === 1) {
dragging = true
panning = e.button === 2 || e.shiftKey
lx = e.clientX; ly = e.clientY
} else {
dragging = false
beginPinch()
stopSpin()
}
}
const onPointerUp = (e) => {
pointers.delete(e.pointerId)
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
pinchDistance = 0
if (pointers.size === 1) {
const remaining = pointers.values().next().value
dragging = true
panning = false
lx = remaining.x; ly = remaining.y
} else {
dragging = false
}
}
const onPointerMove = (e) => {
if (!pointers.has(e.pointerId)) return
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.size >= 2) {
const [a, b] = pointerPair()
const nextDistance = Math.hypot(b.x - a.x, b.y - a.y)
const nextX = (a.x + b.x) / 2
const nextY = (a.y + b.y) / 2
if (pinchDistance > 0 && nextDistance > 0) {
dist *= pinchDistance / nextDistance
dist = Math.max(0.3, Math.min(8, dist))
panX += (nextX - pinchX) * 0.0015 * dist
panY -= (nextY - pinchY) * 0.0015 * dist
}
pinchDistance = nextDistance
pinchX = nextX
pinchY = nextY
return
}
if (!dragging) return
const dx = e.clientX - lx, dy = e.clientY - ly
lx = e.clientX; ly = e.clientY
if (panning) {
panX += dx * 0.0015 * dist; panY -= dy * 0.0015 * dist
} else {
// Compose screen-axis turns onto the current orientation (fixed camera
// frame), so rotation stays screen-relative and never locks up.
const k = 0.008
rot = Q.norm(Q.mul(Q.axisAngle(1, 0, 0, dy * k), Q.mul(Q.axisAngle(0, 1, 0, dx * k), rot)))
stopSpin()
}
}
const onContextMenu = (e) => e.preventDefault()
const onWheel = (e) => {
e.preventDefault()
dist *= Math.exp(e.deltaY * 0.001)
dist = Math.max(0.3, Math.min(8, dist))
}
const onDblClick = () => resetView()
let onSpinChange = null
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointerup', onPointerUp)
canvas.addEventListener('pointercancel', onPointerUp)
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('contextmenu', onContextMenu)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('dblclick', onDblClick)
gl.enable(gl.DEPTH_TEST)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.clearColor(0.063, 0.078, 0.094, 1)
let rafId = 0
let last = performance.now()
function frame(now) {
if (disposed) return
const dt = (now - last) / 1000; last = now
// auto-rotate: a slow turn about the screen-vertical axis (turntable feel)
if (spin) rot = Q.norm(Q.mul(Q.axisAngle(0, 1, 0, dt * 0.4), rot))
const w = canvas.clientWidth, h = canvas.clientHeight
if (w > 0 && h > 0 && (canvas.width !== w * devicePixelRatio || canvas.height !== h * devicePixelRatio)) {
canvas.width = w * devicePixelRatio; canvas.height = h * devicePixelRatio
}
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
if (nIndices) {
const rotation = Q.toMat4(rot)
const model = M.mul(rotation, M.mul(M.scale(fitScale), M.trans(-center[0], -center[1], -center[2])))
const view = M.trans(panX, panY, -dist)
const viewModel = M.mul(view, model)
const proj = M.persp(0.9, (w || 1) / (h || 1), 0.05, 100)
const mvp = M.mul(proj, viewModel)
gl.useProgram(prog)
gl.uniformMatrix4fv(uMVP, false, mvp)
gl.uniformMatrix4fv(uViewModel, false, viewModel)
gl.uniform1f(uMetallicFactor, metallicFactor)
gl.uniform1f(uRoughnessFactor, roughnessFactor)
if (colorMode === 2) {
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, baseColorTexture)
gl.uniform1i(uBaseColorTex, 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, mrTexture)
gl.uniform1i(uMrTex, 1)
}
gl.bindVertexArray(vao)
if (wire) {
// hidden-line wireframe: a depth-only prepass (pushed back a hair via
// polygon offset) occludes back-facing edges, so the front surface's
// edges show instead of a see-through blob.
gl.enable(gl.POLYGON_OFFSET_FILL)
gl.polygonOffset(1.0, 1.0)
gl.colorMask(false, false, false, false)
gl.uniform1i(uWire, 0)
gl.uniform1i(uColorMode, 0)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo)
gl.drawElements(gl.TRIANGLES, nIndices, gl.UNSIGNED_INT, 0)
gl.colorMask(true, true, true, true)
gl.disable(gl.POLYGON_OFFSET_FILL)
gl.uniform1i(uWire, 1)
// Front-surface edges sit at ~equal depth to the offset-back fill, so
// LEQUAL lets them pass while occluded edges (greater depth) fail.
gl.depthFunc(gl.LEQUAL)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, wireIbo)
gl.drawElements(gl.LINES, nWire, gl.UNSIGNED_INT, 0)
gl.depthFunc(gl.LESS)
} else {
gl.uniform1i(uWire, 0)
gl.uniform1i(uColorMode, colorMode)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo)
gl.drawElements(gl.TRIANGLES, nIndices, gl.UNSIGNED_INT, 0)
}
gl.bindVertexArray(null)
}
rafId = requestAnimationFrame(frame)
}
rafId = requestAnimationFrame(frame)
function dispose() {
disposed = true
cancelAnimationFrame(rafId)
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('contextmenu', onContextMenu)
canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('dblclick', onDblClick)
canvas.removeEventListener('webglcontextlost', contextLost)
dropTextures()
gl.deleteBuffer(vbo); gl.deleteBuffer(nbo); gl.deleteBuffer(cbo)
gl.deleteBuffer(mbo); gl.deleteBuffer(ubo); gl.deleteBuffer(ibo)
gl.deleteBuffer(wireIbo)
gl.deleteVertexArray(vao)
gl.deleteProgram(prog)
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
return {
setMesh,
clear,
dispose,
resetView,
setWire(v) { wire = v },
setSpin(v) { spin = v },
onSpinChanged(fn) { onSpinChange = fn },
}
}
export default function GlbViewer({ blob }) {
const { t } = useTranslation('media')
const canvasRef = useRef(null)
const viewerRef = useRef(null)
const [glError, setGlError] = useState(null) // 'no-webgl2' | 'context-lost' | parse error text
const [stats, setStats] = useState(null)
const [wire, setWire] = useState(false)
const [spin, setSpin] = useState(true)
useEffect(() => { // GL lifecycle — once per mount
let viewer = null
try {
viewer = createGlbViewer(canvasRef.current, { onContextLost: () => setGlError('context-lost') })
} catch {
viewer = null
}
if (!viewer) {
setGlError('no-webgl2')
return undefined
}
viewer.onSpinChanged(setSpin)
viewerRef.current = viewer
return () => {
viewerRef.current = null
viewer.dispose()
}
}, [])
useEffect(() => { // (re)load when the blob changes
if (!blob) {
viewerRef.current?.clear()
setStats(null)
return undefined
}
let cancelled = false
;(async () => {
try {
// Parse before touching GL: stats and parse errors surface even when
// WebGL2 is unavailable (e.g. headless CI), and the download button
// keeps working either way.
const mesh = parseGlb(await blob.arrayBuffer())
if (cancelled) return
setStats({
nVerts: mesh.nVerts,
nTris: mesh.nTris,
pbr: !!(mesh.color0 || mesh.baseColorPng),
})
if (viewerRef.current) await viewerRef.current.setMesh(mesh)
} catch (err) {
if (!cancelled) setGlError(err.message)
}
})()
return () => { cancelled = true }
}, [blob])
const toggleWire = () => {
const next = !wire
setWire(next)
viewerRef.current?.setWire(next)
}
const toggleSpin = () => {
const next = !spin
setSpin(next)
viewerRef.current?.setSpin(next)
}
return (
<div className="glb-viewer" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-sm)', width: '100%' }}>
<canvas
ref={canvasRef}
style={{ width: '100%', aspectRatio: '4 / 3', borderRadius: 'var(--radius-md)', background: '#101418', touchAction: 'none' }}
data-testid="glb-canvas"
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
<button type="button" className={`btn btn-sm ${wire ? 'btn-primary' : 'btn-secondary'}`} onClick={toggleWire}>
<i className="fas fa-border-none" /> {t('threed.viewer.wireframe')}
</button>
<button type="button" className={`btn btn-sm ${spin ? 'btn-primary' : 'btn-secondary'}`} onClick={toggleSpin}>
<i className="fas fa-rotate" /> {t('threed.viewer.autoRotate')}
</button>
{stats && (
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.85em' }} data-testid="glb-stats">
{t('threed.viewer.stats', { verts: stats.nVerts.toLocaleString(), tris: stats.nTris.toLocaleString() })}
{stats.pbr ? ' · PBR' : ''}
</span>
)}
</div>
{glError === 'no-webgl2' && <p style={{ color: 'var(--color-text-muted)' }}>{t('threed.viewer.noWebgl')}</p>}
{glError === 'context-lost' && <p style={{ color: 'var(--color-text-muted)' }}>{t('threed.viewer.contextLost')}</p>}
{glError && glError !== 'no-webgl2' && glError !== 'context-lost' && (
<p style={{ color: 'var(--color-danger, #e5484d)' }}>{glError}</p>
)}
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8em', margin: 0 }}>{t('threed.viewer.hint')}</p>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { memo, useState } from 'react'
import { relativeTime } from '../utils/format'
import { useTranslation } from 'react-i18next'
// ThreeDHistory — sibling of MediaHistory for IndexedDB-backed 3D entries
// (see use3DHistory). Reuses MediaHistory's markup, CSS classes, and testids
// so styling and e2e helpers carry over; the differences are the entry shape
// (input-image thumbnail, quality subtitle) and the Blob-backed source.
// Deliberately a plain vertical list — no showcase/gallery mode.
export default memo(function ThreeDHistory({ entries, selectedId, onSelect, onDelete, onClearAll }) {
const { t } = useTranslation('media')
const [expanded, setExpanded] = useState(true)
return (
<div className="media-history" data-testid="media-history">
<div
className={`collapsible-header ${expanded ? 'open' : ''}`}
onClick={() => setExpanded(!expanded)}
style={{ display: 'flex', alignItems: 'center' }}
>
<i className="fas fa-chevron-right" />
<span style={{ flex: 1 }}>{t('history.title')} ({entries.length})</span>
{entries.length > 0 && (
<button
className="media-history-clear-btn"
title={t('history.clearTitle')}
onClick={(e) => { e.stopPropagation(); onClearAll() }}
>
<i className="fas fa-trash" />
</button>
)}
</div>
{expanded && (
<div className="media-history-list">
{entries.length === 0 ? (
<div className="media-history-empty">{t('history.empty')}</div>
) : (
entries.map(entry => (
<div
key={entry.id}
className={`media-history-item ${selectedId === entry.id ? 'active' : ''}`}
onClick={() => onSelect(entry.id)}
data-testid="media-history-item"
>
<div className="media-history-item-thumb">
{entry.inputThumb ? (
<img src={entry.inputThumb} alt="" />
) : (
<i className="fas fa-cube" />
)}
</div>
<div className="media-history-item-info">
<div className="media-history-item-top">
<span className="media-history-item-prompt">{entry.name || entry.model}</span>
<span className="media-history-item-time">{relativeTime(entry.createdAt)}</span>
</div>
<div className="media-history-item-model">
{entry.params?.quality ? `${entry.params.quality} · ` : ''}{entry.model}
</div>
</div>
<button
className="media-history-item-delete"
title={t('history.deleteEntry')}
onClick={(e) => { e.stopPropagation(); onDelete(entry.id) }}
data-testid="media-history-delete"
>
<i className="fas fa-times" />
</button>
</div>
))
)}
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,132 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { generateId } from '../utils/format'
// use3DHistory — IndexedDB-backed history of 3D generations. Unlike
// useMediaHistory (localStorage, URL-only), entries here carry the generated
// GLB as a Blob: GLBs are multi-megabyte binaries the server may eventually
// clean up, and IndexedDB is the only browser store that handles blobs of
// that size. Blobs read back from IndexedDB are lazy handles (the bytes are
// not materialized into JS memory until used), so a single store is fine.
//
// Entry: { id, createdAt, name, model,
// params: { seed, steps, textureSteps, guidance, quality, background },
// inputThumb, // small dataURL of the conditioning image
// glb } // Blob
const DB_NAME = 'localai-3d-history'
const DB_VERSION = 1
const STORE = 'generations'
const MAX_ENTRIES = 20
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' }).createIndex('createdAt', 'createdAt')
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
const txDone = (tx) => new Promise((resolve, reject) => {
tx.oncomplete = resolve
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error)
})
async function withStore(mode, fn) {
const db = await openDb()
try {
const tx = db.transaction(STORE, mode)
const result = fn(tx.objectStore(STORE))
await txDone(tx)
return result
} finally {
db.close()
}
}
async function idbGetAll() {
const req = await withStore('readonly', (store) => store.getAll())
return (req.result || []).sort((a, b) => b.createdAt - a.createdAt)
}
// Insert + keep-newest-N eviction in one transaction so a crash between the
// two can't leave the store unbounded.
async function idbPutAndEvict(entry) {
await withStore('readwrite', (store) => {
store.put(entry)
// getAllKeys on the createdAt index yields primary keys oldest-first.
const keysReq = store.index('createdAt').getAllKeys()
keysReq.onsuccess = () => {
const excess = keysReq.result.length - MAX_ENTRIES
for (let i = 0; i < excess; i++) store.delete(keysReq.result[i])
}
})
}
const idbDelete = (id) => withStore('readwrite', (store) => store.delete(id))
const idbClear = () => withStore('readwrite', (store) => store.clear())
export function use3DHistory() {
const [entries, setEntries] = useState([])
const [selectedId, setSelectedId] = useState(null)
const refresh = useCallback(async () => {
try {
setEntries(await idbGetAll())
} catch {
// IndexedDB unavailable (private mode etc.) — degrade to session-only.
setEntries((prev) => prev)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const addEntry = useCallback(async ({ model, params, inputThumb, glb, name }) => {
const entry = { id: generateId(), createdAt: Date.now(), model, params, inputThumb, glb, name }
try {
await idbPutAndEvict(entry)
await refresh()
} catch {
setEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES))
}
return entry
}, [refresh])
const deleteEntry = useCallback(async (id) => {
setSelectedId((prev) => (prev === id ? null : prev))
try {
await idbDelete(id)
await refresh()
} catch {
setEntries((prev) => prev.filter((e) => e.id !== id))
}
}, [refresh])
const clearAll = useCallback(async () => {
setSelectedId(null)
try {
await idbClear()
} catch {
// fall through to the local reset below
}
setEntries([])
}, [])
// Toggles: clicking the selected entry deselects it (back to latest result).
const selectEntry = useCallback((id) => {
setSelectedId((prev) => (prev === id ? null : id))
}, [])
const selectedEntry = useMemo(
() => entries.find((e) => e.id === selectedId) || null,
[entries, selectedId],
)
return { entries, addEntry, deleteEntry, clearAll, selectEntry, selectedId, selectedEntry }
}

View File

@@ -56,6 +56,7 @@ const FILTERS = [
{ key: 'chat', labelKey: 'filters.llm', icon: 'fa-brain' },
{ key: 'image', labelKey: 'filters.image', icon: 'fa-image' },
{ key: 'video', labelKey: 'filters.video', icon: 'fa-video' },
{ key: '3d', labelKey: 'filters.threed', icon: 'fa-cube' },
{ key: 'multimodal', labelKey: 'filters.multimodal', icon: 'fa-shapes' },
{ key: 'vision', labelKey: 'filters.vision', icon: 'fa-eye' },
{ key: 'tts', labelKey: 'filters.tts', icon: 'fa-microphone' },

View File

@@ -2,6 +2,7 @@ import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ImageGen from './ImageGen'
import VideoGen from './VideoGen'
import ThreeDGen from './ThreeDGen'
import TTS from './TTS'
import Sound from './Sound'
import AudioTransform from './AudioTransform'
@@ -10,6 +11,7 @@ import { useAuth } from '../context/AuthContext'
const BASE_TABS = [
{ key: 'images', labelKey: 'studio.tabs.images', icon: 'fas fa-image' },
{ key: 'video', labelKey: 'studio.tabs.video', icon: 'fas fa-video' },
{ key: 'threed', labelKey: 'studio.tabs.threed', icon: 'fas fa-cube' },
{ key: 'tts', labelKey: 'studio.tabs.tts', icon: 'fas fa-headphones' },
{ key: 'sound', labelKey: 'studio.tabs.sound', icon: 'fas fa-music' },
]
@@ -19,6 +21,7 @@ const TRANSFORM_TAB = { key: 'transform', labelKey: 'studio.tabs.transform', ico
const TAB_COMPONENTS = {
images: ImageGen,
video: VideoGen,
threed: ThreeDGen,
tts: TTS,
sound: Sound,
transform: AudioTransform,
@@ -28,19 +31,23 @@ export default function Studio() {
const { t } = useTranslation('media')
const { hasFeature } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = searchParams.get('tab') || 'images'
const requestedTab = searchParams.get('tab') || 'images'
const threeDEnabled = hasFeature('3d')
const transformEnabled = hasFeature('audio_transform')
const activeTab =
((requestedTab === 'threed' && !threeDEnabled) ||
(requestedTab === 'transform' && !transformEnabled))
? 'images'
: requestedTab
// Transform is a distinct capability; only show its tab when enabled.
const tabs = hasFeature('audio_transform') ? [...BASE_TABS, TRANSFORM_TAB] : BASE_TABS
const enabledTabs = BASE_TABS.filter(tab => tab.key !== 'threed' || threeDEnabled)
const tabs = transformEnabled ? [...enabledTabs, TRANSFORM_TAB] : enabledTabs
const setTab = (key) => {
setSearchParams({ tab: key }, { replace: true })
}
const ActiveComponent =
(activeTab === 'transform' && !hasFeature('audio_transform'))
? ImageGen
: (TAB_COMPONENTS[activeTab] || ImageGen)
const ActiveComponent = TAB_COMPONENTS[activeTab] || ImageGen
return (
<div>

View File

@@ -0,0 +1,285 @@
import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_3D } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import GenerationProgress from '../components/GenerationProgress'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import ThreeDHistory from '../components/ThreeDHistory'
import GlbViewer from '../components/GlbViewer'
import MediaInput from '../components/biometrics/MediaInput'
import { threeDApi } from '../utils/api'
import { apiUrl } from '../utils/basePath'
import { use3DHistory } from '../hooks/use3DHistory'
import useObjectUrl from '../hooks/useObjectUrl'
const QUALITIES = ['auto', 'coarse', '512', '1024']
const BACKGROUNDS = ['auto', 'keep', 'black', 'white']
const MAX_3D_INPUT_BYTES = 32 * 1024 * 1024
const REMESH_DETAIL_COARSE = 2.5
const REMESH_DETAIL_FINE = 0.35
function remeshDetail(sliderValue) {
const position = Number(sliderValue) / 100
return REMESH_DETAIL_COARSE * Math.pow(REMESH_DETAIL_FINE / REMESH_DETAIL_COARSE, position)
}
function remeshedName(name = '3d-model.glb') {
return `${name.replace(/\.glb$/i, '')}-remeshed.glb`
}
// Small thumbnail of the conditioning image for the history list — full-size
// data URLs would bloat every IndexedDB entry for no visual gain.
async function makeThumb(dataUrl, size = 96) {
try {
const img = new Image()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = dataUrl
})
const scale = size / Math.max(img.width, img.height, 1)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(img.width * scale))
canvas.height = Math.max(1, Math.round(img.height * scale))
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/jpeg', 0.7)
} catch {
return null
}
}
export default function ThreeDGen() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const { t } = useTranslation('media')
const [model, setModel] = useState(urlModel || '')
const [image, setImage] = useState(null)
const [quality, setQuality] = useState('auto')
const [background, setBackground] = useState('auto')
const [steps, setSteps] = useState('')
const [textureSteps, setTextureSteps] = useState('')
const [guidance, setGuidance] = useState('')
const [seed, setSeed] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null) // { blob, name, model }
const [remeshSlider, setRemeshSlider] = useState(82)
const [remeshState, setRemeshState] = useState(null) // { sourceBlob, blob?, name?, error? }
const [remeshLoading, setRemeshLoading] = useState(false)
const { entries, addEntry, deleteEntry, clearAll, selectEntry, selectedId, selectedEntry } = use3DHistory()
const source = selectedEntry
? { blob: selectedEntry.glb, name: selectedEntry.name, model: selectedEntry.model }
: result
const showingRemesh = !!source && remeshState?.sourceBlob === source.blob && !!remeshState.blob
const active = showingRemesh ? { blob: remeshState.blob, name: remeshState.name } : source
const remeshError = source && remeshState?.sourceBlob === source.blob ? remeshState.error : null
const detail = remeshDetail(remeshSlider)
const downloadUrl = useObjectUrl(active?.blob)
const handleGenerate = async (e) => {
e.preventDefault()
if (!image?.base64) { addToast(t('threed.toasts.noImage'), 'warning'); return }
if (!model) { addToast(t('threed.toasts.noModel'), 'warning'); return }
setLoading(true)
setResult(null)
setRemeshState(null)
setError(null)
const body = { model, image: image.base64, quality, background, response_format: 'url' }
if (steps) body.step = parseInt(steps)
if (textureSteps) body.texture_steps = parseInt(textureSteps)
if (guidance) body.cfg_scale = parseFloat(guidance)
if (seed) body.seed = parseInt(seed)
try {
const data = await threeDApi.generate(body)
const url = data?.data?.[0]?.url
if (!url) {
addToast(t('threed.toasts.noResults'), 'warning')
return
}
const glbResp = await fetch(apiUrl(url))
if (!glbResp.ok) throw new Error(`fetching the generated GLB failed: HTTP ${glbResp.status}`)
const glb = await glbResp.blob()
const name = url.split('/').pop()
setResult({ blob: glb, name, model })
selectEntry(null)
const inputThumb = image.dataUrl ? await makeThumb(image.dataUrl) : null
await addEntry({
model,
params: { quality, background, steps, textureSteps, guidance, seed },
inputThumb,
glb,
name,
})
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleRemesh = async () => {
if (!source?.blob || !source.model) return
if (showingRemesh) {
setRemeshState(null)
return
}
const sourceBlob = source.blob
setRemeshLoading(true)
setRemeshState({ sourceBlob, error: null })
try {
const blob = await threeDApi.remesh(sourceBlob, source.model, detail)
setRemeshState({ sourceBlob, blob, name: remeshedName(source.name), error: null })
} catch (err) {
setRemeshState({ sourceBlob, error: err.message })
} finally {
setRemeshLoading(false)
}
}
const handleRemeshDetail = (value) => {
setRemeshSlider(value)
if (source && remeshState?.sourceBlob === source.blob) setRemeshState(null)
}
return (
<div className="media-layout">
<div className="media-controls">
<PageHeader title={<><i className="fas fa-cube" /> {t('threed.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">{t('threed.labels.model')}</label>
<ModelSelector value={model} onChange={setModel} capability={CAP_3D} />
</div>
<MediaInput
mode="image"
label={t('threed.labels.image')}
value={image}
onChange={setImage}
onError={(err) => addToast(err.message, 'error')}
maxBytes={MAX_3D_INPUT_BYTES}
idPrefix="threed"
/>
<div className="form-grid-2col">
<div className="form-group">
<label className="form-label">{t('threed.labels.quality')}</label>
<select className="input btn-full" value={quality} onChange={(e) => setQuality(e.target.value)}>
{QUALITIES.map(q => <option key={q} value={q}>{t(`threed.labels.quality_${q}`)}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">{t('threed.labels.background')}</label>
<select className="input btn-full" value={background} onChange={(e) => setBackground(e.target.value)}>
{BACKGROUNDS.map(b => <option key={b} value={b}>{t(`threed.labels.background_${b}`)}</option>)}
</select>
</div>
</div>
<button
type="button"
className={`collapsible-header ${showAdvanced ? 'open' : ''}`}
aria-expanded={showAdvanced}
aria-controls="threed-advanced-options"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<i className="fas fa-chevron-right" aria-hidden="true" /> {t('threed.labels.advanced')}
</button>
{showAdvanced && (
<div id="threed-advanced-options" className="form-grid-2col">
<div className="form-group"><label className="form-label">{t('threed.labels.steps')}</label><input className="input" type="number" min="1" value={steps} onChange={(e) => setSteps(e.target.value)} placeholder="12" /></div>
<div className="form-group"><label className="form-label">{t('threed.labels.textureSteps')}</label><input className="input" type="number" min="1" value={textureSteps} onChange={(e) => setTextureSteps(e.target.value)} placeholder="12" /></div>
<div className="form-group"><label className="form-label">{t('threed.labels.guidance')}</label><input className="input" type="number" step="0.1" value={guidance} onChange={(e) => setGuidance(e.target.value)} placeholder="7.5" /></div>
<div className="form-group"><label className="form-label">{t('threed.labels.seed')}</label><input className="input" type="number" value={seed} onChange={(e) => setSeed(e.target.value)} placeholder={t('threed.labels.seedPlaceholder')} /></div>
</div>
)}
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> {t('threed.actions.generating')}</> : <><i className="fas fa-cube" /> {t('threed.actions.generate')}</>}
</button>
</form>
<ThreeDHistory
entries={entries}
selectedId={selectedId}
onSelect={selectEntry}
onDelete={deleteEntry}
onClearAll={clearAll}
/>
</div>
<div className="media-preview">
<div className="media-result">
{loading ? (
<GenerationProgress label={t('threed.actions.generating')} />
) : error ? (
<ErrorWithTraceLink message={error} />
) : active?.blob ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)', width: '100%' }}>
<GlbViewer blob={active.blob} />
<div className="threed-remesh-controls">
<div className="threed-remesh-heading">
<span>{t('threed.remesh.title')}</span>
<output htmlFor="threed-remesh-detail">{detail.toFixed(2)}%</output>
</div>
<input
id="threed-remesh-detail"
type="range"
min="0"
max="100"
step="1"
value={remeshSlider}
onChange={(e) => handleRemeshDetail(e.target.value)}
disabled={remeshLoading}
aria-label={t('threed.remesh.detail')}
/>
<div className="threed-remesh-scale" aria-hidden="true">
<span>{t('threed.remesh.coarser')}</span>
<span>{t('threed.remesh.finer')}</span>
</div>
<p className="form-hint">{t('threed.remesh.hint')}</p>
<button
type="button"
className="btn btn-secondary btn-full"
onClick={handleRemesh}
disabled={remeshLoading}
data-testid="glb-remesh"
>
{remeshLoading
? <><LoadingSpinner size="sm" /> {t('threed.actions.remeshing')}</>
: showingRemesh
? <><i className="fas fa-rotate-left" /> {t('threed.actions.showOriginal')}</>
: <><i className="fas fa-cubes-stacked" /> {t('threed.actions.remesh')}</>}
</button>
{remeshError && <p className="form-error" role="alert">{remeshError}</p>}
{showingRemesh && <p className="threed-remesh-ready">{t('threed.remesh.ready')}</p>}
</div>
<a
className="btn btn-secondary"
href={downloadUrl}
download={active.name || `3d-${model || 'model'}.glb`}
data-testid="glb-download"
>
<i className="fas fa-download" /> {t('threed.actions.download')}
</a>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-cube" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<p>{t('threed.empty')}</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -74,6 +74,8 @@ const TYPE_COLORS = {
transcription: { bg: 'var(--color-warning-light)', color: 'var(--color-data-4)' },
image_generation: { bg: 'var(--color-success-light)', color: 'var(--color-data-5)' },
video_generation: { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
'3d_generation': { bg: 'var(--color-success-light)', color: 'var(--color-data-5)' },
'3d_remesh': { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
tts: { bg: 'var(--color-warning-light)', color: 'var(--color-data-6)' },
sound_generation: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
rerank: { bg: 'var(--color-primary-light)', color: 'var(--color-data-1)' },

View File

@@ -38,6 +38,7 @@ const Models = page('models', () => import('./pages/Models'))
const Manage = page('manage', () => import('./pages/Manage'))
const ImageGen = page('image', () => import('./pages/ImageGen'))
const VideoGen = page('video', () => import('./pages/VideoGen'))
const ThreeDGen = page('3d', () => import('./pages/ThreeDGen'))
const TTS = page('tts', () => import('./pages/TTS'))
const Sound = page('sound', () => import('./pages/Sound'))
const AudioTransform = page('transform', () => import('./pages/AudioTransform'))
@@ -110,6 +111,8 @@ const appChildren = [
{ path: 'image/:model', element: <ImageGen /> },
{ path: 'video', element: <VideoGen /> },
{ path: 'video/:model', element: <VideoGen /> },
{ path: '3d', element: <Feature feature="3d"><ThreeDGen /></Feature> },
{ path: '3d/:model', element: <Feature feature="3d"><ThreeDGen /></Feature> },
{ path: 'tts', element: <TTS /> },
{ path: 'tts/:model', element: <TTS /> },
{ path: 'sound', element: <Sound /> },

View File

@@ -274,6 +274,22 @@ export const videoApi = {
generate: (body) => postJSON(API_CONFIG.endpoints.video, body),
}
export const threeDApi = {
generate: (body) => postJSON(API_CONFIG.endpoints.threeDGenerations, body),
remesh: async (mesh, model, detail) => {
const form = new FormData()
form.append('model', model)
form.append('detail', String(detail))
form.append('mesh', mesh, 'source.glb')
const response = await fetch(apiUrl(API_CONFIG.endpoints.threeDRemesh), {
method: 'POST',
body: form,
})
await handleResponse(response)
return response.blob()
},
}
// parseAudioBlobResponse — shared response handling for audio-blob endpoints.
// Throws on non-2xx (with the API error message when present); returns the
// blob plus the parsed Content-Disposition filename mapped to the server's

View File

@@ -17,6 +17,11 @@ export const CAP_VAD = 'FLAG_VAD'
export const CAP_DIARIZATION = 'FLAG_DIARIZATION'
export const CAP_SOUND_CLASSIFICATION = 'FLAG_SOUND_CLASSIFICATION'
export const CAP_VIDEO = 'FLAG_VIDEO'
// Wire format note: /api/models/capabilities serves KnownUsecaseStrings, which
// syncKnownUsecasesFromString rewrites to the UPPERCASE keys of
// GetAllModelConfigUsecases() — so this must be FLAG_3D, not the lowercase
// "3d" served by the OpenAI-style /v1/models/capabilities endpoint.
export const CAP_3D = 'FLAG_3D'
export const CAP_DETECTION = 'FLAG_DETECTION'
export const CAP_FACE_RECOGNITION = 'FLAG_FACE_RECOGNITION'
export const CAP_SPEAKER_RECOGNITION = 'FLAG_SPEAKER_RECOGNITION'

View File

@@ -108,6 +108,8 @@ export const API_CONFIG = {
// LocalAI-specific
tts: '/tts',
video: '/video',
threeDGenerations: '/3d/generations',
threeDRemesh: '/3d/remesh',
backendMonitor: '/backend/monitor',
backendShutdown: '/backend/shutdown',
backendLoad: '/backend/load',

144
core/http/react-ui/src/utils/glb.js vendored Normal file
View File

@@ -0,0 +1,144 @@
// Minimal GLB (binary glTF 2.0) parser for the two forms trellis2cpp's
// t2_bake_glb emits (see mesh_export.cpp in localai-org/trellis2cpp):
//
// Form B (default, dense vertex PBR):
// POSITION (VEC3 f32), NORMAL (VEC3 f32),
// COLOR_0 (VEC4 u16 normalized, LINEAR color + alpha),
// _METALLIC_ROUGHNESS (VEC2 u8 normalized: metallic, roughness),
// indices (SCALAR u32); material carries average metallic/roughness factors.
//
// Form A (opt-in via T2GLB_XATLAS, UV atlas):
// POSITION/NORMAL/TEXCOORD_0 f32 + u32 indices,
// baseColorTexture (sRGB PNG) + metallicRoughnessTexture
// (glTF convention: G=roughness, B=metallic).
//
// Deliberately unsupported (the baker never emits them; anything else throws
// so the page can show the error while the download button still works):
// sparse accessors, interleaved bufferViews (byteStride), Draco, multiple
// meshes/primitives.
const GLB_MAGIC = 0x46546c67
const CHUNK_JSON = 0x4e4f534a
const CHUNK_BIN = 0x004e4942
const COMPONENT_ARRAYS = {
5121: Uint8Array,
5123: Uint16Array,
5125: Uint32Array,
5126: Float32Array,
}
const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }
export function parseGlb(buf) {
if (!(buf instanceof ArrayBuffer) || buf.byteLength < 20) throw new Error('not a GLB file')
const dv = new DataView(buf)
if (dv.getUint32(0, true) !== GLB_MAGIC) throw new Error('not a GLB file')
let offset = 12
let json = null
let binOffset = -1
let binLength = 0
while (offset + 8 <= buf.byteLength) {
const len = dv.getUint32(offset, true)
const type = dv.getUint32(offset + 4, true)
const payload = offset + 8
if (type === CHUNK_JSON && !json) {
json = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, payload, len)))
} else if (type === CHUNK_BIN && binOffset < 0) {
binOffset = payload
binLength = len
}
offset = payload + len + ((4 - (len % 4)) % 4)
}
if (!json) throw new Error('GLB has no JSON chunk')
const accessor = (index) => {
const a = json.accessors?.[index]
if (!a) throw new Error(`missing accessor ${index}`)
if (a.sparse) throw new Error('sparse accessors not supported')
const view = json.bufferViews?.[a.bufferView]
if (!view) throw new Error(`missing bufferView ${a.bufferView}`)
if (view.byteStride) throw new Error('interleaved bufferViews not supported')
const ArrayType = COMPONENT_ARRAYS[a.componentType]
const size = TYPE_SIZES[a.type]
if (!ArrayType || !size) throw new Error(`unsupported accessor layout ${a.componentType}/${a.type}`)
const start = binOffset + (view.byteOffset || 0) + (a.byteOffset || 0)
if (binOffset < 0 || start + a.count * size * ArrayType.BYTES_PER_ELEMENT > binOffset + binLength) {
throw new Error('accessor outside the BIN chunk')
}
return {
array: new ArrayType(buf, start, a.count * size),
size,
normalized: !!a.normalized,
min: a.min,
max: a.max,
count: a.count,
}
}
const prim = json.meshes?.[0]?.primitives?.[0]
if (!prim) throw new Error('GLB has no mesh')
const attrs = prim.attributes || {}
if (attrs.POSITION === undefined) throw new Error('GLB mesh has no POSITION attribute')
const position = accessor(attrs.POSITION)
const normal = attrs.NORMAL !== undefined ? accessor(attrs.NORMAL) : null
let indices
if (prim.indices !== undefined) {
const idx = accessor(prim.indices)
indices = idx.array instanceof Uint32Array ? idx.array : Uint32Array.from(idx.array)
} else {
indices = new Uint32Array(position.count)
for (let i = 0; i < indices.length; i++) indices[i] = i
}
const color0 = attrs.COLOR_0 !== undefined ? accessor(attrs.COLOR_0) : null
const metalRough = attrs._METALLIC_ROUGHNESS !== undefined ? accessor(attrs._METALLIC_ROUGHNESS) : null
const uv = attrs.TEXCOORD_0 !== undefined ? accessor(attrs.TEXCOORD_0) : null
const material = json.materials?.[prim.material]?.pbrMetallicRoughness || {}
const imageBytes = (textureIndex) => {
if (textureIndex === undefined) return null
const source = json.textures?.[textureIndex]?.source
const view = json.bufferViews?.[json.images?.[source]?.bufferView]
if (!view) return null
return new Uint8Array(buf, binOffset + (view.byteOffset || 0), view.byteLength)
}
// POSITION min/max are mandatory in glTF, but compute a fallback so a
// technically-invalid file still frames correctly.
let bboxMin = position.min
let bboxMax = position.max
if (!bboxMin || !bboxMax) {
bboxMin = [Infinity, Infinity, Infinity]
bboxMax = [-Infinity, -Infinity, -Infinity]
for (let i = 0; i < position.array.length; i += 3) {
for (let k = 0; k < 3; k++) {
const v = position.array[i + k]
if (v < bboxMin[k]) bboxMin[k] = v
if (v > bboxMax[k]) bboxMax[k] = v
}
}
}
return {
positions: position.array,
normals: normal ? normal.array : null,
indices,
color0,
metalRough,
uv: uv ? uv.array : null,
baseColorPng: imageBytes(material.baseColorTexture?.index),
metalRoughPng: imageBytes(material.metallicRoughnessTexture?.index),
material: {
metallicFactor: material.metallicFactor ?? 1,
roughnessFactor: material.roughnessFactor ?? 1,
},
bboxMin,
bboxMax,
nVerts: position.count,
nTris: Math.floor(indices.length / 3),
}
}

View File

@@ -56,6 +56,8 @@ export default defineConfig({
'/generated-audio': backendUrl,
'/generated-images': backendUrl,
'/generated-videos': backendUrl,
'/generated-3d': backendUrl,
'/3d': backendUrl,
'/version': backendUrl,
'/system': backendUrl,
},

View File

@@ -2,6 +2,7 @@ package routes
import (
"github.com/labstack/echo/v4"
echomiddleware "github.com/labstack/echo/v4/middleware"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
@@ -208,6 +209,17 @@ func RegisterLocalAIRoutes(router *echo.Echo,
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_VIDEO)),
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.VideoRequest) }))
model3dHandler := localai.Model3DEndpoint(cl, ml, appConfig)
router.POST("/3d/generations",
model3dHandler,
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_3D)),
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.Model3DRequest) }))
router.POST("/3d/remesh",
localai.Model3DRemeshEndpoint(ml, appConfig),
echomiddleware.BodyLimit("513M"),
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_3D)),
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.Model3DRemeshRequest) }))
// Backend Statistics Module
// TODO: Should these use standard middlewares? Refactor later, they are extremely simple.
backendMonitorService := monitoring.NewBackendMonitorService(ml, cl, appConfig) // Split out for now
@@ -333,6 +345,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"voice_profiles": "/api/voice-profiles",
"vad": "/vad",
"video": "/video",
"3d_generation": "/3d/generations",
"detection": "/v1/detection",
"tokenize": "/v1/tokenize",
},

View File

@@ -46,6 +46,7 @@ var usecaseFilters = map[string]config.ModelConfigUsecase{
config.UsecaseChat: config.FLAG_CHAT,
config.UsecaseImage: config.FLAG_IMAGE,
config.UsecaseVideo: config.FLAG_VIDEO,
config.Usecase3D: config.FLAG_3D,
config.UsecaseVision: config.FLAG_VISION,
config.UsecaseTTS: config.FLAG_TTS,
config.UsecaseTranscript: config.FLAG_TRANSCRIPT,

View File

@@ -0,0 +1,13 @@
package routes
import (
"testing"
"github.com/mudler/LocalAI/core/config"
"github.com/onsi/gomega"
)
func TestUsecaseFiltersIncludes3D(t *testing.T) {
g := gomega.NewWithT(t)
g.Expect(usecaseFilters[config.Usecase3D]).To(gomega.Equal(config.FLAG_3D))
}

View File

@@ -70,6 +70,29 @@ type VideoRequest struct {
Params map[string]string `json:"params,omitempty" yaml:"params,omitempty"` // backend-specific generation parameters
}
// @Description 3D asset generation request body. Generation is image-conditioned
// (TRELLIS.2 has no text-prompt path); the response is a binary glTF (.glb).
type Model3DRequest struct {
BasicModelRequest
Image string `json:"image" yaml:"image"` // conditioning image: URL, base64, or data URI (required)
Seed int32 `json:"seed,omitempty" yaml:"seed,omitempty"` // random seed; <=0 picks a random seed
Step int32 `json:"step,omitempty" yaml:"step,omitempty"` // flow sampling steps (backend default 12)
CFGScale float32 `json:"cfg_scale,omitempty" yaml:"cfg_scale,omitempty"` // classifier-free guidance scale (backend default 7.5)
TextureSteps int32 `json:"texture_steps,omitempty" yaml:"texture_steps,omitempty"` // texture flow sampling steps (backend default 12)
Quality string `json:"quality,omitempty" yaml:"quality,omitempty"` // mesh pipeline: auto|coarse|512|1024
Background string `json:"background,omitempty" yaml:"background,omitempty"` // background handling: auto|keep|black|white
ResponseFormat string `json:"response_format,omitempty" yaml:"response_format,omitempty"` // output format (url or b64_json)
Params map[string]string `json:"params,omitempty" yaml:"params,omitempty"` // backend-specific generation parameters
}
// @Description Print-remesh an existing trellis2.cpp GLB. The multipart mesh
// is wrapped into a watertight manifold; detail is a percentage of the source
// bounding-box diagonal and the enclosing offset is derived automatically.
type Model3DRemeshRequest struct {
BasicModelRequest
Detail float32 `json:"detail,omitempty" yaml:"detail,omitempty" form:"detail"` // detail size in percent (0.352.5; default 0.5)
}
// @Description TTS request body
type TTSRequest struct {
BasicModelRequest

View File

@@ -7,7 +7,7 @@ type LocalAIRequest interface {
// @Description BasicModelRequest contains the basic model request fields
type BasicModelRequest struct {
Model string `json:"model,omitempty" yaml:"model,omitempty"`
Model string `json:"model,omitempty" yaml:"model,omitempty" form:"model"`
// TODO: Should this also include the following fields from the OpenAI side of the world?
// If so, changes should be made to core/http/middleware/request.go to match

View File

@@ -0,0 +1,46 @@
package nodes
import (
"context"
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
grpc "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
ggrpc "google.golang.org/grpc"
)
type capturing3DBackend struct {
grpc.Backend
request *pb.Generate3DRequest
}
func (b *capturing3DBackend) Generate3D(_ context.Context, request *pb.Generate3DRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
b.request = request
return &pb.Result{Success: true}, nil
}
type failingFetchStager struct {
fakeFileStager
}
func (f *failingFetchStager) FetchRemote(_ context.Context, _, _, _ string) error {
return errors.New("transfer failed")
}
var _ = Describe("FileStagingClient 3D output", func() {
It("returns an error when the generated asset cannot be retrieved", func(ctx SpecContext) {
backend := &capturing3DBackend{}
stager := &failingFetchStager{}
client := NewFileStagingClient(backend, stager, "worker-1")
request := &pb.Generate3DRequest{Dst: "/data/generated/asset.glb"}
result, err := client.Generate3D(ctx, request)
Expect(result).To(Equal(&pb.Result{Success: true}))
Expect(err).To(MatchError(ContainSubstring("retrieving generated 3D asset: transfer failed")))
Expect(backend.request.Dst).To(Equal("/remote/tmp"))
})
})

View File

@@ -209,6 +209,42 @@ func (f *FileStagingClient) GenerateVideo(ctx context.Context, in *pb.GenerateVi
return result, nil
}
func (f *FileStagingClient) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
reqID := requestID()
// Stage the conditioning image or existing GLB used by 3D post-processing.
if in.Src != "" && isFilePath(in.Src) {
backendPath, _, err := f.stageInputFile(ctx, reqID, in.Src, "inputs")
if err != nil {
return nil, fmt.Errorf("staging 3D input asset: %w", err)
}
in.Src = backendPath
}
// Handle output destination
frontendDst := in.Dst
if frontendDst != "" {
tmpPath, err := f.stager.AllocRemoteTemp(ctx, f.nodeID)
if err != nil {
return nil, fmt.Errorf("allocating temp for 3D output: %w", err)
}
in.Dst = tmpPath
}
result, err := f.Backend.Generate3D(ctx, in, opts...)
if err != nil {
return result, err
}
if frontendDst != "" && in.Dst != frontendDst {
if err := f.retrieveOutputFile(ctx, in.Dst, frontendDst); err != nil {
return result, fmt.Errorf("retrieving generated 3D asset: %w", err)
}
}
return result, nil
}
func (f *FileStagingClient) TTS(ctx context.Context, in *pb.TTSRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
reqID := requestID()

View File

@@ -157,6 +157,9 @@ func (c *fakeBackendClient) GenerateImage(_ context.Context, _ *pb.GenerateImage
func (c *fakeBackendClient) GenerateVideo(_ context.Context, _ *pb.GenerateVideoRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}
func (c *fakeBackendClient) Generate3D(_ context.Context, _ *pb.Generate3DRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}
func (c *fakeBackendClient) TTS(_ context.Context, _ *pb.TTSRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return nil, nil
}

View File

@@ -144,6 +144,12 @@ func (c *InFlightTrackingClient) GenerateVideo(ctx context.Context, in *pb.Gener
return res, c.reconcile(err)
}
func (c *InFlightTrackingClient) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
defer c.track(ctx)()
res, err := c.inner.Generate3D(ctx, in, opts...)
return res, c.reconcile(err)
}
func (c *InFlightTrackingClient) TTS(ctx context.Context, in *pb.TTSRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
defer c.track(ctx)()
res, err := c.inner.TTS(ctx, in, opts...)

View File

@@ -87,6 +87,10 @@ func (f *fakeGRPCBackend) GenerateVideo(_ context.Context, _ *pb.GenerateVideoRe
return &pb.Result{}, nil
}
func (f *fakeGRPCBackend) Generate3D(_ context.Context, _ *pb.Generate3DRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return &pb.Result{}, nil
}
func (f *fakeGRPCBackend) TTS(_ context.Context, _ *pb.TTSRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
return &pb.Result{}, nil
}

View File

@@ -24,6 +24,8 @@ const (
BackendTraceTranscription BackendTraceType = "transcription"
BackendTraceImageGeneration BackendTraceType = "image_generation"
BackendTraceVideoGeneration BackendTraceType = "video_generation"
BackendTrace3DGeneration BackendTraceType = "3d_generation"
BackendTrace3DRemesh BackendTraceType = "3d_remesh"
BackendTraceTTS BackendTraceType = "tts"
BackendTraceSoundGeneration BackendTraceType = "sound_generation"
BackendTraceRerank BackendTraceType = "rerank"

View File

@@ -0,0 +1,122 @@
+++
disableToc = false
title = "3D Generation"
weight = 19
url = "/features/3d-generation/"
+++
LocalAI can generate textured 3D meshes from a single conditioning image via the `/3d/generations` endpoint, powered by the `trellis2cpp` backend — a C++/GGML port of [Microsoft TRELLIS.2](https://github.com/microsoft/TRELLIS.2) ([trellis2.cpp](https://github.com/localai-org/trellis2cpp)). The output is a binary glTF (`.glb`) asset with PBR materials.
Generation is image-conditioned only — there is no text-prompt path. Provide a photo or rendering of a single object (ideally on a plain background) and TRELLIS.2 reconstructs a full 3D mesh from it.
## Setup
Install a model from the gallery:
```bash
local-ai run trellis2-4b # full pipeline: 1024³ cascade + PBR textures (~18 GB)
# or
local-ai run trellis2-4b-geometry # 512³ untextured geometry only (~7 GB)
```
The backend detects which component GGUFs are present and degrades gracefully: without the texture models it produces untextured geometry, and without the fine-flow models it falls back to a coarse marching-cubes preview.
## API
- **Method:** `POST`
- **Endpoint:** `/3d/generations`
### Request
The request body is JSON with the following fields:
| Parameter | Type | Required | Default | Description |
|-------------------|----------|----------|---------|--------------------------------------------------------------------|
| `model` | `string` | Yes | | Model name to use |
| `image` | `string` | Yes | | Conditioning image as base64, a data URI, or a public URL |
| `quality` | `string` | No | `auto` | Mesh pipeline: `auto`, `coarse`, `512`, or `1024` |
| `background` | `string` | No | `auto` | Background handling: `auto`, `keep`, `black`, or `white` |
| `step` | `int` | No | 12 | Flow sampling steps for the shape |
| `texture_steps` | `int` | No | 12 | Flow sampling steps for the PBR material |
| `cfg_scale` | `float` | No | 7.5 | Classifier-free guidance scale |
| `seed` | `int` | No | random | Random seed for reproducibility |
| `response_format` | `string` | No | `url` | `url` to return a file URL, `b64_json` for base64 output |
| `params` | `object` | No | | Backend-specific string parameters (`texture_size`, `components`) |
`quality` selects the mesh resolution: `coarse` is a fast marching-cubes preview, `512` the fine dual-grid mesh, `1024` the high-resolution cascade (slow — several minutes, roughly 10 GB VRAM), and `auto` picks the best pipeline the installed model set supports.
`background` controls solid-background removal on the conditioning image before generation: `auto` detects border-connected near-black/near-white, `keep` preserves the image alpha exactly, and `black`/`white` force removal of that colour.
Backend-specific `params`: `texture_size` (UV-atlas resolution hint when atlas baking is enabled) and `components` (`tiny` removes small islands, `largest` keeps only the biggest connected component, `all` — the default — keeps everything).
### Response
Returns a JSON response using LocalAI's OpenAI-style generation envelope:
| Field | Type | Description |
|-------------------|----------|----------------------------------------------------------------|
| `created` | `int` | Unix timestamp of generation |
| `id` | `string` | Unique identifier (UUID) |
| `data` | `array` | Array with the generated asset |
| `data[].url` | `string` | URL path to the `.glb` under `/generated-3d` (if `url`) |
| `data[].b64_json` | `string` | Base64-encoded GLB (if `response_format` is `b64_json`) |
### Watertight print remeshing
`POST /3d/remesh` applies the same post-generation CGAL Alpha Wrap workflow as the trellis2.cpp demo. It accepts `multipart/form-data` and returns the remeshed GLB directly as `model/gltf-binary`:
| Field | Type | Required | Default | Description |
|----------|----------|----------|---------|-------------|
| `model` | `string` | Yes | | Installed TRELLIS.2 model name |
| `mesh` | `file` | Yes | | Source GLB produced by TRELLIS.2 |
| `detail` | `float` | No | `0.5` | Smallest preserved detail as a percentage of the source bounding-box diagonal (`0.35``2.5`) |
There is intentionally no independent offset control. The enclosing offset follows the trellis2.cpp demo and is derived as `detail / 30`; independent tuning tends to produce puffy or degenerate wraps. Lower detail percentages retain finer features but take longer and generally produce more triangles. The output is watertight, oriented, intersection-free, and 2-manifold. For textured sources, LocalAI unwraps the replacement mesh and reprojects its PBR material onto a new UV atlas.
Source GLBs may be up to 512 MiB. This route uses its own upload limit because fine TRELLIS.2 meshes commonly exceed LocalAI's default `--upload-limit`.
```bash
curl http://localhost:8080/3d/remesh \
-F model=trellis2-4b \
-F mesh=@generated.glb \
-F detail=0.5 \
--output printable.glb
```
## Usage
### Generate a 3D model from an image
```bash
curl http://localhost:8080/3d/generations \
-H "Content-Type: application/json" \
-d '{
"model": "trellis2-4b",
"image": "https://example.com/photo-of-a-chair.png",
"quality": "512"
}'
```
The response contains a URL such as `/generated-3d/b64123456789.glb`; fetch it from the same server. The GLB is standard glTF 2.0 and opens in Blender, three.js, `<model-viewer>`, and most engines.
### Base64 input and output
```bash
curl http://localhost:8080/3d/generations \
-H "Content-Type: application/json" \
-d "{
\"model\": \"trellis2-4b\",
\"image\": \"$(base64 -w0 chair.png)\",
\"response_format\": \"b64_json\"
}" | jq -r '.data[0].b64_json' | base64 -d > chair.glb
```
## WebUI
The React UI includes a 3D tab in the Studio (and a `/3d` page) with an interactive PBR viewer: upload an image, pick the quality, and preview the generated mesh with orbit/pan/zoom and a wireframe toggle. Past generations are kept in the browser (IndexedDB). After generation, a single Detail slider and **Apply remeshing** button replace the preview with the exact watertight model that the GLB download exports; **Show original** switches back without regenerating.
## Notes
- The 512³ pipeline takes roughly two minutes on a modern GPU; the 1024³ cascade takes around five minutes and needs about 10 GB VRAM plus a temporary host-RAM spike.
- `TRELLIS2_DEVICE=cpu` forces CPU inference (slow; mainly for debugging).
- The generated mesh has unoriented winding (faithful to TRELLIS.2) and is exported Y-up with vertex-PBR materials; a UV-atlas texture bake can be enabled in the backend via the `T2GLB_XATLAS` environment variable.

View File

@@ -135,6 +135,7 @@ LocalAI supports various types of backends:
- **Sound Generation Backends**: For music and audio generation (e.g., ACE-Step)
- **Sound Classification Backends**: For sound-event classification / audio tagging - identifying everyday sounds like baby cry, glass breaking, alarms (e.g., ced.cpp)
- **Image & Video Generation Backends**: For diffusion and audio-conditioned avatar models (e.g., stable-diffusion.cpp, diffusers, vLLM-Omni, [LongCat-Video]({{%relref "features/video-generation" %}}))
- **3D Generation Backends**: For image-to-3D mesh generation ([trellis2.cpp]({{%relref "features/3d-generation" %}}) — Microsoft TRELLIS.2, producing GLB assets with PBR textures)
- **Vision & Detection Backends**: For object detection, segmentation, depth, and face/voice recognition (e.g., rf-detr.cpp, locate-anything.cpp, sam3.cpp, insightface)
- **Audio Processing Backends**: For voice activity detection and audio enhancement (e.g., Silero VAD, LocalVQE)
- **Utility Backends**: For reranking, PII/NER token classification, fine-tuning, quantization, and vector storage (e.g., rerankers, privacy-filter.cpp, TRL, local-store)

View File

@@ -31288,6 +31288,101 @@
- filename: clip_vision_h.safetensors
sha256: ""
uri: https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors
- name: trellis2-4b
url: github:mudler/LocalAI/gallery/trellis2cpp.yaml@master
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/1583646260758-5e64858c87403103f9f1055d.png
urls:
- https://huggingface.co/microsoft/TRELLIS.2-4B
- https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF
- https://huggingface.co/LocalAI-io/TRELLIS-image-large-GGUF
- https://huggingface.co/LocalAI-io/dinov3-vitl16-pretrain-lvd1689m-GGUF
description: |
Microsoft TRELLIS.2-4B image-to-3D generation via trellis2.cpp (C++/ggml).
Generates a textured 3D mesh (binary glTF / GLB with PBR materials) from a
single conditioning image. This is the full pipeline: coarse preview, 512
fine dual-grid, 1024 cascade, and PBR texturing (~18 GB of f16 GGUFs;
the 1024 textured path needs roughly 10 GB VRAM).
license: mit
tags:
- trellis
- image-to-3d
- 3d-generation
- gguf
last_checked: "2026-07-16"
overrides:
parameters:
model: ss_flow_f16.gguf
files:
- filename: dino_f16.gguf
sha256: 385d8186a38a2328ec740fb2ac1f33f9194d8774efc7ccafd4aa2e51cf5f6450
uri: huggingface://LocalAI-io/dinov3-vitl16-pretrain-lvd1689m-GGUF/dino_f16.gguf
- filename: ss_flow_f16.gguf
sha256: 1dded5b74237d24e6876a642a26f90b43742e3554418573860f810e3bbe61e8c
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/ss_flow_f16.gguf
- filename: ss_dec_f16.gguf
sha256: 9c2210b7ed830fdc8286961a8189878ff5bcfd3bfc83ab4eacee005d293d2185
uri: huggingface://LocalAI-io/TRELLIS-image-large-GGUF/ss_dec_f16.gguf
- filename: slat_flow_f16.gguf
sha256: 2f94bad7b1c524ad8c01943bc38fcc0c314e7d482ce896f3c6e96eb6e7cec15c
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/slat_flow_f16.gguf
- filename: slat_flow_1024_f16.gguf
sha256: b6a2270131e2e9235e9b6cb525193eb85ae132fa5af3274322aacd39e40a6bc5
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/slat_flow_1024_f16.gguf
- filename: shape_dec_f16.gguf
sha256: 6fe53f1d7763dabf7c8d72bc38f4053d87fde6f65bf17a9d378d27edb39d3530
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/shape_dec_f16.gguf
- filename: shape_enc_f16.gguf
sha256: 3ec80ff580987fcdb9bc594fc8b6fda890d63101ca442eb2b26f5dc315e8696c
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/shape_enc_f16.gguf
- filename: tex_dec_f16.gguf
sha256: afd304f4dfcb8c94df851b85519b415b99f04070f7d29de1320c50631b1be4e0
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/tex_dec_f16.gguf
- filename: tex_slat_flow_512_f16.gguf
sha256: 89a081b7f5487a5b31f03d240e4d959a56db0cc2c46c327230097a2554da52ae
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/tex_slat_flow_512_f16.gguf
- filename: tex_slat_flow_1024_f16.gguf
sha256: bbb55b0910c7929aac5e0612a9bb15113837a2c674cafb9f0f170eda8b5558a8
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/tex_slat_flow_1024_f16.gguf
- name: trellis2-4b-geometry
url: github:mudler/LocalAI/gallery/trellis2cpp.yaml@master
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/1583646260758-5e64858c87403103f9f1055d.png
urls:
- https://huggingface.co/microsoft/TRELLIS.2-4B
- https://huggingface.co/LocalAI-io/TRELLIS.2-4B-GGUF
- https://huggingface.co/LocalAI-io/TRELLIS-image-large-GGUF
- https://huggingface.co/LocalAI-io/dinov3-vitl16-pretrain-lvd1689m-GGUF
description: |
Microsoft TRELLIS.2-4B image-to-3D generation via trellis2.cpp (C++/ggml) —
geometry-only variant (~7 GB): coarse preview plus the 512 fine dual-grid
mesh, without the 1024 cascade or PBR texturing. The backend detects the
reduced model set automatically; install trellis2-4b instead for textured
output.
license: mit
tags:
- trellis
- image-to-3d
- 3d-generation
- gguf
last_checked: "2026-07-16"
overrides:
parameters:
model: ss_flow_f16.gguf
files:
- filename: dino_f16.gguf
sha256: 385d8186a38a2328ec740fb2ac1f33f9194d8774efc7ccafd4aa2e51cf5f6450
uri: huggingface://LocalAI-io/dinov3-vitl16-pretrain-lvd1689m-GGUF/dino_f16.gguf
- filename: ss_flow_f16.gguf
sha256: 1dded5b74237d24e6876a642a26f90b43742e3554418573860f810e3bbe61e8c
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/ss_flow_f16.gguf
- filename: ss_dec_f16.gguf
sha256: 9c2210b7ed830fdc8286961a8189878ff5bcfd3bfc83ab4eacee005d293d2185
uri: huggingface://LocalAI-io/TRELLIS-image-large-GGUF/ss_dec_f16.gguf
- filename: slat_flow_f16.gguf
sha256: 2f94bad7b1c524ad8c01943bc38fcc0c314e7d482ce896f3c6e96eb6e7cec15c
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/slat_flow_f16.gguf
- filename: shape_dec_f16.gguf
sha256: 6fe53f1d7763dabf7c8d72bc38f4053d87fde6f65bf17a9d378d27edb39d3530
uri: huggingface://LocalAI-io/TRELLIS.2-4B-GGUF/shape_dec_f16.gguf
- name: sd-1.5-ggml
url: github:mudler/LocalAI/gallery/sd-ggml.yaml@master
urls:

9
gallery/trellis2cpp.yaml Normal file
View File

@@ -0,0 +1,9 @@
---
name: "trellis2cpp"
config_file: |
backend: trellis2cpp
known_usecases:
- 3d
step: 12
cfg_scale: 7.5

View File

@@ -73,6 +73,7 @@ type InferenceBackend interface {
Predict(ctx context.Context, in *pb.PredictOptions, opts ...grpc.CallOption) (*pb.Reply, error)
GenerateImage(ctx context.Context, in *pb.GenerateImageRequest, opts ...grpc.CallOption) (*pb.Result, error)
GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...grpc.CallOption) (*pb.Result, error)
Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...grpc.CallOption) (*pb.Result, error)
TTS(ctx context.Context, in *pb.TTSRequest, opts ...grpc.CallOption) (*pb.Result, error)
TTSStream(ctx context.Context, in *pb.TTSRequest, f func(reply *pb.Reply), opts ...grpc.CallOption) error
SoundGeneration(ctx context.Context, in *pb.SoundGenerationRequest, opts ...grpc.CallOption) (*pb.Result, error)

View File

@@ -59,6 +59,10 @@ func (llm *Base) GenerateVideo(*pb.GenerateVideoRequest) error {
return fmt.Errorf("unimplemented")
}
func (llm *Base) Generate3D(*pb.Generate3DRequest) error {
return fmt.Errorf("unimplemented")
}
func (llm *Base) AudioTranscription(context.Context, *pb.TranscriptRequest) (pb.TranscriptResult, error) {
return pb.TranscriptResult{}, fmt.Errorf("unimplemented")
}

View File

@@ -247,6 +247,23 @@ func (c *Client) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest,
return client.GenerateVideo(ctx, in, opts...)
}
func (c *Client) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...grpc.CallOption) (*pb.Result, error) {
if !c.parallel {
c.opMutex.Lock()
defer c.opMutex.Unlock()
}
c.setBusy(true)
defer c.setBusy(false)
defer c.wdMark()()
conn, err := c.dial()
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
client := pb.NewBackendClient(conn)
return client.Generate3D(ctx, in, opts...)
}
func (c *Client) TTS(ctx context.Context, in *pb.TTSRequest, opts ...grpc.CallOption) (*pb.Result, error) {
if !c.parallel {
c.opMutex.Lock()

View File

@@ -53,6 +53,10 @@ func (e *embedBackend) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRe
return e.s.GenerateVideo(ctx, in)
}
func (e *embedBackend) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...grpc.CallOption) (*pb.Result, error) {
return e.s.Generate3D(ctx, in)
}
func (e *embedBackend) TTS(ctx context.Context, in *pb.TTSRequest, opts ...grpc.CallOption) (*pb.Result, error) {
return e.s.TTS(ctx, in)
}

View File

@@ -18,6 +18,7 @@ type AIModel interface {
Embeddings(*pb.PredictOptions) ([]float32, error)
GenerateImage(*pb.GenerateImageRequest) error
GenerateVideo(*pb.GenerateVideoRequest) error
Generate3D(*pb.Generate3DRequest) error
Detect(*pb.DetectOptions) (pb.DetectResponse, error)
Depth(*pb.DepthRequest) (pb.DepthResponse, error)
FaceVerify(*pb.FaceVerifyRequest) (pb.FaceVerifyResponse, error)

View File

@@ -165,6 +165,18 @@ func (s *server) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest)
return &pb.Result{Message: "Video generated", Success: true}, nil
}
func (s *server) Generate3D(ctx context.Context, in *pb.Generate3DRequest) (*pb.Result, error) {
if s.llm.Locking() {
s.llm.Lock()
defer s.llm.Unlock()
}
err := s.llm.Generate3D(in)
if err != nil {
return &pb.Result{Message: fmt.Sprintf("Error generating 3D asset: %s", err.Error()), Success: false}, err
}
return &pb.Result{Message: "3D asset generated", Success: true}, nil
}
func (s *server) TTS(ctx context.Context, in *pb.TTSRequest) (*pb.Result, error) {
if err := s.checkModelIdentity(in); err != nil {
return nil, err

View File

@@ -72,6 +72,12 @@ func (c *ConnectionEvictingClient) GenerateVideo(ctx context.Context, in *pb.Gen
return result, err
}
func (c *ConnectionEvictingClient) Generate3D(ctx context.Context, in *pb.Generate3DRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
result, err := c.Backend.Generate3D(ctx, in, opts...)
c.checkErr(err)
return result, err
}
func (c *ConnectionEvictingClient) TTS(ctx context.Context, in *pb.TTSRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
result, err := c.Backend.TTS(ctx, in, opts...)
c.checkErr(err)

View File

@@ -43,6 +43,7 @@ var TypeAlias = map[string]string{
const (
WhisperBackend = "whisper"
StableDiffusionGGMLBackend = "stablediffusion-ggml"
Trellis2CppBackend = "trellis2cpp"
TransformersBackend = "transformers"
LocalStoreBackend = "local-store"

View File

@@ -50,6 +50,12 @@ export function inferBackendPath(item) {
if (item.backend === "magpie-tts-cpp") {
return `backend/go/magpie-tts-cpp/`;
}
// trellis2cpp is a Go backend (Dockerfile.golang) wrapping the trellis2.cpp
// ggml port via purego, living in backend/go/trellis2cpp/. Keep the mapping
// explicit so a future dockerfile-suffix change cannot break path filtering.
if (item.backend === "trellis2cpp") {
return `backend/go/trellis2cpp/`;
}
if (item.dockerfile.endsWith("golang")) {
return `backend/go/${item.backend}/`;
}

View File

@@ -8,7 +8,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { filterMatrix } from "./backend-filter.mjs";
import { filterMatrix, inferBackendPath } from "./backend-filter.mjs";
test("trellis2cpp maps to its Go backend source directory", () => {
assert.equal(
inferBackendPath({
backend: "trellis2cpp",
dockerfile: "./backend/Dockerfile.golang",
}),
"backend/go/trellis2cpp/"
);
});
// A representative slice of .github/backend-matrix.yml: one entry per build
// path that the filter treats differently. Kept inline rather than parsed from

View File

@@ -22,6 +22,77 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/3d/generations": {
"post": {
"tags": [
"3d"
],
"summary": "Creates a 3D asset (binary glTF / GLB) from a conditioning image.",
"parameters": [
{
"description": "query params",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.Model3DRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.OpenAIResponse"
}
}
}
}
},
"/3d/remesh": {
"post": {
"consumes": [
"multipart/form-data"
],
"produces": [
"model/gltf-binary"
],
"tags": [
"3d"
],
"summary": "Applies watertight print remeshing to an existing 3D asset.",
"parameters": [
{
"type": "string",
"description": "3D model name",
"name": "model",
"in": "formData",
"required": true
},
{
"type": "file",
"description": "Source GLB",
"name": "mesh",
"in": "formData",
"required": true
},
{
"type": "number",
"description": "Detail size as percent of the source bounding-box diagonal (0.352.5; default 0.5)",
"name": "detail",
"in": "formData"
}
],
"responses": {
"200": {
"description": "Remeshed GLB",
"schema": {
"type": "file"
}
}
}
}
},
"/api/agent/jobs": {
"get": {
"produces": [
@@ -5662,6 +5733,54 @@ const docTemplate = `{
}
}
},
"schema.Model3DRequest": {
"description": "3D asset generation request body. Generation is image-conditioned",
"type": "object",
"properties": {
"background": {
"description": "background handling: auto|keep|black|white",
"type": "string"
},
"cfg_scale": {
"description": "classifier-free guidance scale (backend default 7.5)",
"type": "number"
},
"image": {
"description": "conditioning image: URL, base64, or data URI (required)",
"type": "string"
},
"model": {
"type": "string"
},
"params": {
"description": "backend-specific generation parameters",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"quality": {
"description": "mesh pipeline: auto|coarse|512|1024",
"type": "string"
},
"response_format": {
"description": "output format (url or b64_json)",
"type": "string"
},
"seed": {
"description": "random seed; \u003c=0 picks a random seed",
"type": "integer"
},
"step": {
"description": "flow sampling steps (backend default 12)",
"type": "integer"
},
"texture_steps": {
"description": "texture flow sampling steps (backend default 12)",
"type": "integer"
}
}
},
"schema.ModelCapabilities": {
"type": "object",
"properties": {

View File

@@ -19,6 +19,77 @@
},
"basePath": "/",
"paths": {
"/3d/generations": {
"post": {
"tags": [
"3d"
],
"summary": "Creates a 3D asset (binary glTF / GLB) from a conditioning image.",
"parameters": [
{
"description": "query params",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.Model3DRequest"
}
}
],
"responses": {
"200": {
"description": "Response",
"schema": {
"$ref": "#/definitions/schema.OpenAIResponse"
}
}
}
}
},
"/3d/remesh": {
"post": {
"consumes": [
"multipart/form-data"
],
"produces": [
"model/gltf-binary"
],
"tags": [
"3d"
],
"summary": "Applies watertight print remeshing to an existing 3D asset.",
"parameters": [
{
"type": "string",
"description": "3D model name",
"name": "model",
"in": "formData",
"required": true
},
{
"type": "file",
"description": "Source GLB",
"name": "mesh",
"in": "formData",
"required": true
},
{
"type": "number",
"description": "Detail size as percent of the source bounding-box diagonal (0.352.5; default 0.5)",
"name": "detail",
"in": "formData"
}
],
"responses": {
"200": {
"description": "Remeshed GLB",
"schema": {
"type": "file"
}
}
}
}
},
"/api/agent/jobs": {
"get": {
"produces": [
@@ -5659,6 +5730,54 @@
}
}
},
"schema.Model3DRequest": {
"description": "3D asset generation request body. Generation is image-conditioned",
"type": "object",
"properties": {
"background": {
"description": "background handling: auto|keep|black|white",
"type": "string"
},
"cfg_scale": {
"description": "classifier-free guidance scale (backend default 7.5)",
"type": "number"
},
"image": {
"description": "conditioning image: URL, base64, or data URI (required)",
"type": "string"
},
"model": {
"type": "string"
},
"params": {
"description": "backend-specific generation parameters",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"quality": {
"description": "mesh pipeline: auto|coarse|512|1024",
"type": "string"
},
"response_format": {
"description": "output format (url or b64_json)",
"type": "string"
},
"seed": {
"description": "random seed; \u003c=0 picks a random seed",
"type": "integer"
},
"step": {
"description": "flow sampling steps (backend default 12)",
"type": "integer"
},
"texture_steps": {
"description": "texture flow sampling steps (backend default 12)",
"type": "integer"
}
}
},
"schema.ModelCapabilities": {
"type": "object",
"properties": {

View File

@@ -1444,6 +1444,41 @@ definitions:
$ref: '#/definitions/schema.ToolCall'
type: array
type: object
schema.Model3DRequest:
description: 3D asset generation request body. Generation is image-conditioned
properties:
background:
description: 'background handling: auto|keep|black|white'
type: string
cfg_scale:
description: classifier-free guidance scale (backend default 7.5)
type: number
image:
description: 'conditioning image: URL, base64, or data URI (required)'
type: string
model:
type: string
params:
additionalProperties:
type: string
description: backend-specific generation parameters
type: object
quality:
description: 'mesh pipeline: auto|coarse|512|1024'
type: string
response_format:
description: output format (url or b64_json)
type: string
seed:
description: random seed; <=0 picks a random seed
type: integer
step:
description: flow sampling steps (backend default 12)
type: integer
texture_steps:
description: texture flow sampling steps (backend default 12)
type: integer
type: object
schema.ModelCapabilities:
properties:
capabilities:
@@ -2722,6 +2757,53 @@ info:
title: LocalAI API
version: 2.0.0
paths:
/3d/generations:
post:
parameters:
- description: query params
in: body
name: request
required: true
schema:
$ref: '#/definitions/schema.Model3DRequest'
responses:
"200":
description: Response
schema:
$ref: '#/definitions/schema.OpenAIResponse'
summary: Creates a 3D asset (binary glTF / GLB) from a conditioning image.
tags:
- 3d
/3d/remesh:
post:
consumes:
- multipart/form-data
parameters:
- description: 3D model name
in: formData
name: model
required: true
type: string
- description: Source GLB
in: formData
name: mesh
required: true
type: file
- description: Detail size as percent of the source bounding-box diagonal (0.352.5;
default 0.5)
in: formData
name: detail
type: number
produces:
- model/gltf-binary
responses:
"200":
description: Remeshed GLB
schema:
type: file
summary: Applies watertight print remeshing to an existing 3D asset.
tags:
- 3d
/api/agent/jobs:
get:
parameters: