diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js index 3a3300ada..470304aa7 100644 --- a/core/http/react-ui/e2e/threed-gen.spec.js +++ b/core/http/react-ui/e2e/threed-gen.spec.js @@ -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('') + 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) diff --git a/core/http/react-ui/src/components/GlbViewer.jsx b/core/http/react-ui/src/components/GlbViewer.jsx index ead0c8a32..b00b18e01 100644 --- a/core/http/react-ui/src/components/GlbViewer.jsx +++ b/core/http/react-ui/src/components/GlbViewer.jsx @@ -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 }, } } diff --git a/core/http/react-ui/src/components/biometrics/MediaInput.jsx b/core/http/react-ui/src/components/biometrics/MediaInput.jsx index c72203ca5..28143d8cd 100644 --- a/core/http/react-ui/src/components/biometrics/MediaInput.jsx +++ b/core/http/react-ui/src/components/biometrics/MediaInput.jsx @@ -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 ( -
+
{label && }
@@ -133,6 +157,9 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB accept={mode === 'image' ? 'image/*' : 'audio/*'} onChange={handleFile} /> + {mode === 'image' && ( +