mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
* 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>
320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
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/)
|
|
})
|
|
})
|
|
})
|