fix(react-ui): restore 3D Studio results and history (#11393)

* fix(react-ui): restore 3D Studio results and history

Keep large conditioning-image payloads out of the rendered request panel so the generated viewer can mount reliably. Accept clipboard images and synchronize 3D history consumers so new results appear in Studio without a reload.

Cover clipboard input, bounded request rendering, result display, and cross-view history synchronization with Playwright.

Assisted-by: Codex:gpt-5 Playwright

* perf(react-ui): idle the 3D viewport when still

Limit auto-rotate rendering to 30 FPS and stop scheduling frames when rotation is disabled. Resize, view controls, and pointer input invalidate the still frame on demand.

Assisted-by: Codex:gpt-5 Playwright
This commit is contained in:
Richard Palethorpe authored and GitHub committed 2026-08-06 17:34:46 +02:00
1 parent ea438cdeaf
commit 5ac445e1d4
6 files changed
+183 -25

No files matched your search

+82
View File
@@ -125,6 +125,22 @@ async function generateOnce(page) {
await page.locator('button[type="submit"]').click()
}
async function pasteImage(page) {
await page.locator('.biometrics-mediainput').focus()
await page.evaluate((base64) => {
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0))
const transfer = new DataTransfer()
transfer.items.add(new File([bytes], 'clipboard.png', { type: 'image/png' }))
const target = document.querySelector('.biometrics-mediainput')
target.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: transfer,
}))
}, TINY_PNG.toString('base64'))
await expect(page.locator('.biometrics-mediainput__source-pill')).toContainText('Pasted image')
}
test.describe('3D generation', () => {
test.beforeEach(async ({ page }) => {
await mockCapabilities(page)
@@ -154,6 +170,60 @@ test.describe('3D generation', () => {
expect(requestBody.response_format).toBe('url')
})
test('caps auto-rotate at 30 FPS and renders still models on demand', async ({ page }) => {
await page.addInitScript(() => {
window.__glDrawTimes = []
const proto = window.WebGL2RenderingContext?.prototype
if (!proto) return
const drawElements = proto.drawElements
proto.drawElements = function (...args) {
window.__glDrawTimes.push(performance.now())
return drawElements.apply(this, args)
}
})
await mockGeneration(page)
await generateOnce(page)
await expect(page.getByTestId('glb-stats')).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(100)
await page.evaluate(() => { window.__glDrawTimes = [] })
await page.waitForTimeout(600)
const drawTimes = await page.evaluate(() => window.__glDrawTimes)
test.skip(drawTimes.length < 3, 'WebGL2 drawing is unavailable in this browser')
expect(drawTimes.length).toBeLessThanOrEqual(22)
const gaps = drawTimes.slice(1).map((time, index) => time - drawTimes[index]).sort((a, b) => a - b)
expect(gaps[Math.floor(gaps.length / 2)]).toBeGreaterThan(25)
await page.getByRole('button', { name: 'Auto-rotate' }).click()
await page.waitForTimeout(100)
const stoppedAt = await page.evaluate(() => window.__glDrawTimes.length)
await page.waitForTimeout(250)
const idleAt = await page.evaluate(() => window.__glDrawTimes.length)
expect(idleAt - stoppedAt).toBeLessThanOrEqual(1)
await page.getByTestId('glb-canvas').dispatchEvent('wheel', { deltaY: 20 })
await expect.poll(() => page.evaluate(() => window.__glDrawTimes.length)).toBeGreaterThan(idleAt)
})
test('pastes a conditioning image without mounting its base64 in the request panel', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
await page.goto('/app/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await pasteImage(page)
await page.locator('button[type="submit"]').click()
await expect(page.getByTestId('glb-stats')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
const panel = page.locator('.request-panel')
await expect(panel).toContainText('<base64 image/png omitted>')
const panelText = await panel.textContent()
expect(panelText.length).toBeLessThan(2000)
expect(panelText).not.toContain(requestBody.image)
expect(requestBody.image).toBeTruthy()
})
test('advanced settings map to step/texture_steps/cfg_scale/seed', async ({ page }) => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
@@ -226,6 +296,18 @@ test.describe('3D generation', () => {
await expect(page.getByTestId('glb-download')).toHaveAttribute('href', /^blob:/)
})
test('new history is visible on the Studio overview without a reload', async ({ page }) => {
await mockGeneration(page)
await page.goto('/app/studio/threed')
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()
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
await page.locator('.studio-tab[data-tab="overview"]').click()
await expect(page.getByTestId('studio-recent')).toContainText('trellis-test-model')
})
test('deleting a history entry removes it', async ({ page }) => {
await mockGeneration(page)
await generateOnce(page)
@@ -132,6 +132,7 @@ const Q = {
// 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)))
const FRAME_INTERVAL_MS = 1000 / 30
/* minimal mat4 helpers (column-major) */
const M = {
@@ -333,10 +334,12 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
nIndices = 0
nWire = 0
dropTextures()
requestRender()
}
function resetView() {
rot = QBASE.slice(); dist = 1.8; panX = panY = 0
requestRender()
}
/* input */
@@ -353,6 +356,7 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
}
const stopSpin = () => {
spin = false
requestRender()
if (onSpinChange) onSpinChange(false)
}
const onPointerDown = (e) => {
@@ -400,6 +404,7 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
pinchDistance = nextDistance
pinchX = nextX
pinchY = nextY
requestRender()
return
}
@@ -415,12 +420,14 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
rot = Q.norm(Q.mul(Q.axisAngle(1, 0, 0, dy * k), Q.mul(Q.axisAngle(0, 1, 0, dx * k), rot)))
stopSpin()
}
requestRender()
}
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))
requestRender()
}
const onDblClick = () => resetView()
let onSpinChange = null
@@ -439,10 +446,26 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
gl.clearColor(0.063, 0.078, 0.094, 1)
let rafId = 0
let last = performance.now()
let lastDraw = 0
let dirty = true
function requestRender() {
dirty = true
if (!disposed && !rafId) rafId = requestAnimationFrame(frame)
}
function frame(now) {
rafId = 0
if (disposed) return
const dt = (now - last) / 1000; last = now
// requestAnimationFrame follows the display refresh rate, which can be
// 120-240 Hz. Skip expensive mesh draws until the 30 FPS budget is due.
if (spin && lastDraw && now - lastDraw < FRAME_INTERVAL_MS) {
rafId = requestAnimationFrame(frame)
return
}
if (!spin && !dirty) return
const dt = lastDraw ? Math.min((now - lastDraw) / 1000, 0.1) : 0
lastDraw = now
dirty = false
// 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))
@@ -503,13 +526,20 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
}
gl.bindVertexArray(null)
}
rafId = requestAnimationFrame(frame)
// A still model is complete until input, resize, or a control invalidates
// it. Spinning models keep scheduling frames, subject to the cap above.
if (spin) rafId = requestAnimationFrame(frame)
}
rafId = requestAnimationFrame(frame)
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(requestRender)
resizeObserver?.observe(canvas)
requestRender()
function dispose() {
disposed = true
cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
@@ -532,8 +562,8 @@ export function createGlbViewer(canvas, { onContextLost } = {}) {
clear,
dispose,
resetView,
setWire(v) { wire = v },
setSpin(v) { spin = v },
setWire(v) { wire = v; requestRender() },
setSpin(v) { spin = v; requestRender() },
onSpinChanged(fn) { onSpinChange = fn },
}
}
@@ -51,9 +51,7 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
if (tab !== 'live' && cap.active) cap.stop()
}, [tab]) // eslint-disable-line react-hooks/exhaustive-deps
const handleFile = async (e) => {
const f = e.target.files?.[0]
if (!f) { onChange(null); return }
const acceptFile = async (f, source = 'file') => {
if (maxBytes && f.size > maxBytes) {
const error = new Error(`Selected file exceeds the ${Math.round(maxBytes / (1024 * 1024))} MiB limit`)
if (fileRef.current) fileRef.current.value = ''
@@ -62,8 +60,11 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
return
}
try {
const name = source === 'paste'
? `pasted-image.${(f.type.split('/')[1] || 'png').replace('+xml', '')}`
: f.name
if (preferBlob) {
onChange({ blob: f, mime: f.type, source: 'file', name: f.name })
onChange({ blob: f, mime: f.type, source, name })
return
}
const base64 = await fileToBase64(f)
@@ -73,13 +74,30 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
reader.onload = () => resolve(reader.result)
reader.readAsDataURL(f)
})
onChange({ base64, blob: f, dataUrl, mime: f.type, source: 'file', name: f.name })
onChange({ base64, blob: f, dataUrl, mime: f.type, source, name })
} catch (error) {
onChange(null)
onError?.(error)
}
}
const handleFile = async (e) => {
const f = e.target.files?.[0]
if (!f) { onChange(null); return }
await acceptFile(f)
}
const handlePaste = async (e) => {
if (mode !== 'image') return
const item = Array.from(e.clipboardData?.items || []).find(entry => entry.type.startsWith('image/'))
const f = item?.getAsFile()
|| Array.from(e.clipboardData?.files || []).find(file => file.type.startsWith('image/'))
if (!f) return
e.preventDefault()
setTab('file')
await acceptFile(f, 'paste')
}
const handleSnap = () => {
const shot = cap.snap()
if (shot) onChange({ ...shot, source: 'live' })
@@ -106,7 +124,13 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
const inputId = `${idPrefix}-${mode}-file`
return (
<div className="biometrics-mediainput">
<div
className="biometrics-mediainput"
onPaste={handlePaste}
tabIndex={mode === 'image' ? 0 : undefined}
role={mode === 'image' ? 'group' : undefined}
aria-label={mode === 'image' ? `${label || 'Image'} upload or clipboard paste` : undefined}
>
{label && <label className="form-label" htmlFor={inputId}>{label}</label>}
<div className="biometrics-mediainput__tabs" role="tablist" aria-label={`${label || 'Media'} source`}>
@@ -133,6 +157,9 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
accept={mode === 'image' ? 'image/*' : 'audio/*'}
onChange={handleFile}
/>
{mode === 'image' && (
<p className="form-hint"><i className="fas fa-clipboard" aria-hidden="true" /> Paste an image from the clipboard</p>
)}
</div>
)}
@@ -184,8 +211,8 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
: <audio controls src={value.dataUrl} />}
<div className="biometrics-mediainput__preview-meta">
<span className="biometrics-mediainput__source-pill">
<i className={`fas ${value.source === 'live' ? (mode === 'image' ? 'fa-camera' : 'fa-microphone') : 'fa-file'}`} aria-hidden="true" />
{value.source === 'live' ? ' Captured' : ` ${value.name || 'Uploaded'}`}
<i className={`fas ${value.source === 'live' ? (mode === 'image' ? 'fa-camera' : 'fa-microphone') : value.source === 'paste' ? 'fa-clipboard' : 'fa-file'}`} aria-hidden="true" />
{value.source === 'live' ? ' Captured' : value.source === 'paste' ? ' Pasted image' : ` ${value.name || 'Uploaded'}`}
</span>
<button type="button" className="biometrics-mediainput__clear" onClick={clear} aria-label="Remove sample">
<i className="fas fa-xmark" aria-hidden="true" />
+26 -9
View File
@@ -17,6 +17,14 @@ const DB_NAME = 'localai-3d-history'
const DB_VERSION = 1
const STORE = 'generations'
const MAX_ENTRIES = 20
const historyListeners = new Set()
let sessionEntries = []
async function refreshOtherHooks(source) {
await Promise.all([...historyListeners]
.filter(listener => listener !== source)
.map(listener => listener()))
}
function openDb() {
return new Promise((resolve, reject) => {
@@ -78,14 +86,19 @@ export function use3DHistory() {
const refresh = useCallback(async () => {
try {
setEntries(await idbGetAll())
sessionEntries = await idbGetAll()
setEntries(sessionEntries)
} catch {
// IndexedDB unavailable (private mode etc.) — degrade to session-only.
setEntries((prev) => prev)
setEntries(sessionEntries)
}
}, [])
useEffect(() => { refresh() }, [refresh])
useEffect(() => {
historyListeners.add(refresh)
refresh()
return () => { historyListeners.delete(refresh) }
}, [refresh])
const addEntry = useCallback(async ({ model, params, inputThumb, glb, name }) => {
const entry = { id: generateId(), createdAt: Date.now(), model, params, inputThumb, glb, name }
@@ -93,8 +106,10 @@ export function use3DHistory() {
await idbPutAndEvict(entry)
await refresh()
} catch {
setEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES))
sessionEntries = [entry, ...sessionEntries.filter(e => e.id !== entry.id)].slice(0, MAX_ENTRIES)
setEntries(sessionEntries)
}
await refreshOtherHooks(refresh)
return entry
}, [refresh])
@@ -104,19 +119,21 @@ export function use3DHistory() {
await idbDelete(id)
await refresh()
} catch {
setEntries((prev) => prev.filter((e) => e.id !== id))
sessionEntries = sessionEntries.filter((e) => e.id !== id)
setEntries(sessionEntries)
}
await refreshOtherHooks(refresh)
}, [refresh])
const clearAll = useCallback(async () => {
setSelectedId(null)
try {
await idbClear()
} catch {
// fall through to the local reset below
}
} catch { /* session-only history is cleared below */ }
sessionEntries = []
setEntries([])
}, [])
await refreshOtherHooks(refresh)
}, [refresh])
// Toggles: clicking the selected entry deselects it (back to latest result).
const selectEntry = useCallback((id) => {
+3 -1
View File
@@ -100,7 +100,9 @@ export default function ThreeDGen() {
if (guidance) body.cfg_scale = parseFloat(guidance)
if (seed) body.seed = parseInt(seed)
setLastRequest(body)
// RequestPanel renders and copies its body. Keeping a multi-megabyte image
// there duplicates the upload in React and can starve the result render.
setLastRequest({ ...body, image: `<base64 ${image.mime || 'image'} omitted>` })
try {
const data = await threeDApi.generate(body)
+1 -1
View File
@@ -113,7 +113,7 @@ curl http://localhost:8080/3d/generations \
## 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.
The React UI includes a 3D tab in the Studio (and a `/3d` page) with an interactive PBR viewer: upload or paste an image from the clipboard, 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