mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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:
committed by
GitHub
parent
8089b2bf09
commit
9058a2bb46
113
.github/backend-matrix.yml
vendored
113
.github/backend-matrix.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/bump_deps.yaml
vendored
4
.github/workflows/bump_deps.yaml
vendored
@@ -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"
|
||||
|
||||
36
.github/workflows/test-extra.yml
vendored
36
.github/workflows/test-extra.yml
vendored
@@ -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
|
||||
|
||||
12
Makefile
12
Makefile
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
6
backend/go/trellis2cpp/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
package/
|
||||
sources/
|
||||
.cache/
|
||||
build-*/
|
||||
variants/
|
||||
trellis2cpp
|
||||
132
backend/go/trellis2cpp/Makefile
Normal file
132
backend/go/trellis2cpp/Makefile
Normal 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
|
||||
252
backend/go/trellis2cpp/glb_mesh.go
Normal file
252
backend/go/trellis2cpp/glb_mesh.go
Normal 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
|
||||
}
|
||||
74
backend/go/trellis2cpp/glb_mesh_test.go
Normal file
74
backend/go/trellis2cpp/glb_mesh_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
50
backend/go/trellis2cpp/main.go
Normal file
50
backend/go/trellis2cpp/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
63
backend/go/trellis2cpp/package.sh
Executable file
63
backend/go/trellis2cpp/package.sh
Executable 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
61
backend/go/trellis2cpp/run.sh
Executable 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 "$@"
|
||||
511
backend/go/trellis2cpp/trellis2.go
Normal file
511
backend/go/trellis2cpp/trellis2.go
Normal 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)
|
||||
}
|
||||
247
backend/go/trellis2cpp/trellis2_test.go
Normal file
247
backend/go/trellis2cpp/trellis2_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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
104
core/backend/model3d.go
Normal 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
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
170
core/gallery/importers/trellis2cpp.go
Normal file
170
core/gallery/importers/trellis2cpp.go
Normal 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
|
||||
}
|
||||
105
core/gallery/importers/trellis2cpp_test.go
Normal file
105
core/gallery/importers/trellis2cpp_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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/") ||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
core/http/body_limit_test.go
Normal file
66
core/http/body_limit_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
186
core/http/endpoints/localai/model3d.go
Normal file
186
core/http/endpoints/localai/model3d.go
Normal 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)
|
||||
}
|
||||
}
|
||||
72
core/http/endpoints/localai/model3d_internal_test.go
Normal file
72
core/http/endpoints/localai/model3d_internal_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
163
core/http/endpoints/localai/model3d_remesh.go
Normal file
163
core/http/endpoints/localai/model3d_remesh.go
Normal 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.35–2.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)
|
||||
}
|
||||
}
|
||||
68
core/http/endpoints/localai/model3d_remesh_internal_test.go
Normal file
68
core/http/endpoints/localai/model3d_remesh_internal_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
|
||||
319
core/http/react-ui/e2e/threed-gen.spec.js
Normal file
319
core/http/react-ui/e2e/threed-gen.spec.js
Normal 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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"llm": "Chat",
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"threed": "3D",
|
||||
"multimodal": "Multimodal",
|
||||
"vision": "Vision",
|
||||
"tts": "TTS",
|
||||
|
||||
@@ -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));
|
||||
|
||||
636
core/http/react-ui/src/components/GlbViewer.jsx
Normal file
636
core/http/react-ui/src/components/GlbViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
core/http/react-ui/src/components/ThreeDHistory.jsx
Normal file
76
core/http/react-ui/src/components/ThreeDHistory.jsx
Normal 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>
|
||||
)
|
||||
})
|
||||
132
core/http/react-ui/src/hooks/use3DHistory.js
vendored
Normal file
132
core/http/react-ui/src/hooks/use3DHistory.js
vendored
Normal 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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
285
core/http/react-ui/src/pages/ThreeDGen.jsx
Normal file
285
core/http/react-ui/src/pages/ThreeDGen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
16
core/http/react-ui/src/utils/api.js
vendored
16
core/http/react-ui/src/utils/api.js
vendored
@@ -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
|
||||
|
||||
5
core/http/react-ui/src/utils/capabilities.js
vendored
5
core/http/react-ui/src/utils/capabilities.js
vendored
@@ -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'
|
||||
|
||||
2
core/http/react-ui/src/utils/config.js
vendored
2
core/http/react-ui/src/utils/config.js
vendored
@@ -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
144
core/http/react-ui/src/utils/glb.js
vendored
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
core/http/routes/ui_api_internal_test.go
Normal file
13
core/http/routes/ui_api_internal_test.go
Normal 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))
|
||||
}
|
||||
@@ -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.35–2.5; default 0.5)
|
||||
}
|
||||
|
||||
// @Description TTS request body
|
||||
type TTSRequest struct {
|
||||
BasicModelRequest
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
46
core/services/nodes/file_staging_3d_test.go
Normal file
46
core/services/nodes/file_staging_3d_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
122
docs/content/features/3d-generation.md
Normal file
122
docs/content/features/3d-generation.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
9
gallery/trellis2cpp.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: "trellis2cpp"
|
||||
|
||||
config_file: |
|
||||
backend: trellis2cpp
|
||||
known_usecases:
|
||||
- 3d
|
||||
step: 12
|
||||
cfg_scale: 7.5
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,6 +43,7 @@ var TypeAlias = map[string]string{
|
||||
const (
|
||||
WhisperBackend = "whisper"
|
||||
StableDiffusionGGMLBackend = "stablediffusion-ggml"
|
||||
Trellis2CppBackend = "trellis2cpp"
|
||||
|
||||
TransformersBackend = "transformers"
|
||||
LocalStoreBackend = "local-store"
|
||||
|
||||
@@ -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}/`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
119
swagger/docs.go
119
swagger/docs.go
@@ -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.35–2.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": {
|
||||
|
||||
@@ -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.35–2.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": {
|
||||
|
||||
@@ -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.35–2.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:
|
||||
|
||||
Reference in New Issue
Block a user