mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 14:14:54 -04:00
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:
1 parent
ea438cdeaf
commit
5ac445e1d4
6 files changed
+183
-25
No files matched your search
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user