refactor: clean up Render Settings — remove 11 unused settings, fix Blender status
Removed from UI (saved to DB but never read by any service): - Max Concurrent Renders, Stall Timeout, Thumbnail Format, Product Thumbnail Priority - Render Linear/Angular Deflection (only Scene deflections are used) - GLB Scale Factor, Smooth Normals, GLB Material Mode, PBR Roughness, PBR Metallic Fixed Blender status check: - Old: called is_blender_available() in backend container (Blender not installed there) - New: dispatches Celery task on asset_pipeline queue → runs in render-worker container - Returns: available=true, version="Blender 5.0.1", binary path - Status card moved to System Tools tab with refresh button Kept active: engine, device, samples, smooth angle, tessellation, scene deflections, 3D viewer zoom limits Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -723,20 +723,14 @@ async def seed_workflows(
|
|||||||
async def renderer_status(
|
async def renderer_status(
|
||||||
admin: User = Depends(require_global_admin),
|
admin: User = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Check health of renderer services."""
|
"""Check Blender availability on the render-worker via Celery task."""
|
||||||
from app.services.render_blender import find_blender, is_blender_available
|
from app.tasks.gpu_tasks import check_blender_status
|
||||||
blender_available = is_blender_available()
|
try:
|
||||||
blender_bin = find_blender()
|
result = check_blender_status.apply_async()
|
||||||
return {
|
data = result.get(timeout=10)
|
||||||
"blender": {
|
except Exception as exc:
|
||||||
"available": blender_available,
|
data = {"available": False, "blender_bin": "", "version": "", "error": str(exc)}
|
||||||
"note": (
|
return {"blender": data}
|
||||||
f"render-worker subprocess ({blender_bin})"
|
|
||||||
if blender_available
|
|
||||||
else "Blender not found — check render-worker container and BLENDER_BIN"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-media-assets")
|
@router.post("/import-media-assets")
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ from app.tasks.celery_app import celery_app
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="app.tasks.gpu_tasks.check_blender_status", queue="asset_pipeline")
|
||||||
|
def check_blender_status() -> dict:
|
||||||
|
"""Quick Blender availability check on the render-worker."""
|
||||||
|
import subprocess
|
||||||
|
from app.services.render_blender import find_blender
|
||||||
|
|
||||||
|
blender_bin = find_blender()
|
||||||
|
if not blender_bin:
|
||||||
|
return {"available": False, "blender_bin": "", "version": ""}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[blender_bin, "--version"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
version = result.stdout.strip().split("\n")[0] if result.returncode == 0 else "unknown"
|
||||||
|
except Exception:
|
||||||
|
version = "unknown"
|
||||||
|
return {"available": True, "blender_bin": blender_bin, "version": version}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="app.tasks.gpu_tasks.probe_gpu", queue="asset_pipeline")
|
@celery_app.task(name="app.tasks.gpu_tasks.probe_gpu", queue="asset_pipeline")
|
||||||
def probe_gpu() -> dict:
|
def probe_gpu() -> dict:
|
||||||
"""Run Blender GPU probe on the render-worker. Stores result in system_settings."""
|
"""Run Blender GPU probe on the render-worker. Stores result in system_settings."""
|
||||||
|
|||||||
+109
-404
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle, Wrench, HardDrive, Mail, Monitor, Eye, Box } from 'lucide-react'
|
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle, HardDrive, Mail, Monitor, Eye, Box } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
@@ -33,7 +33,6 @@ export default function AdminPage() {
|
|||||||
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
||||||
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
|
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
|
||||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
||||||
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
|
|
||||||
|
|
||||||
const { data: users } = useQuery({
|
const { data: users } = useQuery({
|
||||||
queryKey: ['admin-users'],
|
queryKey: ['admin-users'],
|
||||||
@@ -88,12 +87,8 @@ export default function AdminPage() {
|
|||||||
blender_cycles_samples: number
|
blender_cycles_samples: number
|
||||||
blender_eevee_samples: number
|
blender_eevee_samples: number
|
||||||
threejs_render_size: number
|
threejs_render_size: number
|
||||||
thumbnail_format: string
|
|
||||||
blender_smooth_angle: number
|
blender_smooth_angle: number
|
||||||
cycles_device: string
|
cycles_device: string
|
||||||
blender_max_concurrent_renders: number
|
|
||||||
render_stall_timeout_minutes: number
|
|
||||||
product_thumbnail_priority: string // JSON array
|
|
||||||
render_backend: string
|
render_backend: string
|
||||||
smtp_enabled: boolean
|
smtp_enabled: boolean
|
||||||
smtp_host: string
|
smtp_host: string
|
||||||
@@ -101,17 +96,10 @@ export default function AdminPage() {
|
|||||||
smtp_user: string
|
smtp_user: string
|
||||||
smtp_password: string
|
smtp_password: string
|
||||||
smtp_from_address: string
|
smtp_from_address: string
|
||||||
gltf_scale_factor: number
|
|
||||||
gltf_smooth_normals: boolean
|
|
||||||
viewer_max_distance: number
|
viewer_max_distance: number
|
||||||
viewer_min_distance: number
|
viewer_min_distance: number
|
||||||
gltf_material_quality: string
|
|
||||||
gltf_pbr_roughness: number
|
|
||||||
gltf_pbr_metallic: number
|
|
||||||
scene_linear_deflection: number
|
scene_linear_deflection: number
|
||||||
scene_angular_deflection: number
|
scene_angular_deflection: number
|
||||||
render_linear_deflection: number
|
|
||||||
render_angular_deflection: number
|
|
||||||
tessellation_engine: string
|
tessellation_engine: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +128,6 @@ export default function AdminPage() {
|
|||||||
const tess = { ...settings, ...tessellationDraft } as Settings
|
const tess = { ...settings, ...tessellationDraft } as Settings
|
||||||
const [showAdvancedTess, setShowAdvancedTess] = useState(false)
|
const [showAdvancedTess, setShowAdvancedTess] = useState(false)
|
||||||
|
|
||||||
const { data: rendererStatus, refetch: refetchStatus } = useQuery({
|
|
||||||
queryKey: ['renderer-status'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await api.get('/admin/settings/renderer-status')
|
|
||||||
return res.data as Record<string, { available: boolean; note: string; url: string | null }>
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateSettingsMut = useMutation({
|
const updateSettingsMut = useMutation({
|
||||||
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
|
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -325,6 +304,17 @@ export default function AdminPage() {
|
|||||||
type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
|
type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
|
||||||
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
|
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
|
||||||
|
|
||||||
|
// Blender Status (via Celery on render-worker)
|
||||||
|
const { data: blenderStatus, refetch: refetchBlenderStatus, isFetching: blenderStatusFetching } = useQuery({
|
||||||
|
queryKey: ['blender-status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/admin/settings/renderer-status')
|
||||||
|
return res.data.blender as { available: boolean; blender_bin: string; version: string; error?: string }
|
||||||
|
},
|
||||||
|
enabled: isAdmin && activeTab === 'system',
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
const hasUnsavedChanges =
|
const hasUnsavedChanges =
|
||||||
Object.keys(blenderDraft).length > 0 ||
|
Object.keys(blenderDraft).length > 0 ||
|
||||||
Object.keys(viewerDraft).length > 0 ||
|
Object.keys(viewerDraft).length > 0 ||
|
||||||
@@ -549,17 +539,6 @@ export default function AdminPage() {
|
|||||||
<p className="text-sm text-content-muted mb-4">Render engine selection, sample counts, smooth angle, and performance tuning for Blender 5.</p>
|
<p className="text-sm text-content-muted mb-4">Render engine selection, sample counts, smooth angle, and performance tuning for Blender 5.</p>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-content-secondary">Service Status</span>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchStatus()}
|
|
||||||
className="text-content-muted hover:text-content-secondary transition-colors"
|
|
||||||
title="Refresh service status"
|
|
||||||
>
|
|
||||||
<RefreshCw size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* ── Render Quality ─────────────────────────────────────── */}
|
{/* ── Render Quality ─────────────────────────────────────── */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -681,41 +660,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Performance ────────────────────────────────────────── */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Performance</p>
|
|
||||||
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1} max={16} step={1}
|
|
||||||
value={blender.blender_max_concurrent_renders ?? 3}
|
|
||||||
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_max_concurrent_renders: Number(e.target.value) }))}
|
|
||||||
title="Maximum parallel Blender render jobs (1-16). Each job uses ~400 MB RAM. Applied live without restart. Default: 3"
|
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-muted">
|
|
||||||
Max parallel Blender render jobs (1-16). Higher values use more RAM (~400 MB each). Applied live without restart.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={10} max={10080} step={10}
|
|
||||||
value={blender.render_stall_timeout_minutes ?? 120}
|
|
||||||
onChange={(e) => setBlenderDraft((d) => ({ ...d, render_stall_timeout_minutes: Number(e.target.value) }))}
|
|
||||||
title="Minutes before a stuck render job is automatically restarted (10-10080). The watchdog checks every 5 minutes. Default: 120"
|
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-muted">
|
|
||||||
Minutes before a stuck render job is auto-restarted (10-10080). Checked every 5 min by the watchdog.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
{Object.keys(blenderDraft).length > 0 && (
|
{Object.keys(blenderDraft).length > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -727,182 +671,6 @@ export default function AdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Output ────────────────────────────────────────────── */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Output</p>
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Thumbnail format</label>
|
|
||||||
{(['jpg', 'png'] as const).map((fmt) => (
|
|
||||||
<button
|
|
||||||
key={fmt}
|
|
||||||
onClick={() => updateSettingsMut.mutate({ thumbnail_format: fmt })}
|
|
||||||
disabled={updateSettingsMut.isPending}
|
|
||||||
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
|
||||||
settings?.thumbnail_format === fmt
|
|
||||||
? 'text-white'
|
|
||||||
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
|
|
||||||
}`}
|
|
||||||
style={settings?.thumbnail_format === fmt ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
|
|
||||||
>
|
|
||||||
{fmt === 'jpg' ? 'JPEG (smaller)' : 'PNG (lossless)'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<p className="text-xs text-content-muted">
|
|
||||||
{settings?.thumbnail_format === 'jpg'
|
|
||||||
? 'JPEG -- ~3-5x smaller files, minimal quality loss at 92% quality.'
|
|
||||||
: 'PNG -- lossless, larger files.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product thumbnail priority chain */}
|
|
||||||
{(() => {
|
|
||||||
let priorityList: string[] = ['latest_render', 'cad_thumbnail']
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(settings?.product_thumbnail_priority ?? '["latest_render","cad_thumbnail"]')
|
|
||||||
if (Array.isArray(parsed)) priorityList = parsed
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const savePriority = (list: string[]) => {
|
|
||||||
updateSettingsMut.mutate({ product_thumbnail_priority: JSON.stringify(list) } as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveUp = (i: number) => {
|
|
||||||
if (i === 0) return
|
|
||||||
const next = [...priorityList]
|
|
||||||
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
|
|
||||||
savePriority(next)
|
|
||||||
}
|
|
||||||
const moveDown = (i: number) => {
|
|
||||||
if (i === priorityList.length - 1) return
|
|
||||||
const next = [...priorityList]
|
|
||||||
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
|
|
||||||
savePriority(next)
|
|
||||||
}
|
|
||||||
const remove = (i: number) => savePriority(priorityList.filter((_, j) => j !== i))
|
|
||||||
const addEntry = () => {
|
|
||||||
if (!priorityNewEntry || priorityList.includes(priorityNewEntry)) return
|
|
||||||
savePriority([...priorityList, priorityNewEntry])
|
|
||||||
setPriorityNewEntry('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryLabel = (e: string) =>
|
|
||||||
e === 'cad_thumbnail' ? 'CAD Thumbnail'
|
|
||||||
: e === 'latest_render' ? 'Latest Render (any type)'
|
|
||||||
: outputTypes?.find((ot) => ot.id === e)?.name ?? `Output type ...${e.slice(-8)}`
|
|
||||||
|
|
||||||
const entryColor = (e: string) =>
|
|
||||||
e === 'cad_thumbnail' ? 'bg-surface-alt border-border-default text-content-muted'
|
|
||||||
: e === 'latest_render' ? 'bg-status-info-bg border-border-default text-status-info-text'
|
|
||||||
: 'bg-status-success-bg border-border-default text-status-success-text'
|
|
||||||
|
|
||||||
// Options not yet in the list
|
|
||||||
const addableOptions = [
|
|
||||||
...(['latest_render', 'cad_thumbnail'] as string[]).filter((v) => !priorityList.includes(v)),
|
|
||||||
...(outputTypes ?? []).filter((ot) => !priorityList.includes(ot.id)).map((ot) => ot.id),
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-4 pt-1">
|
|
||||||
<label className="text-sm font-medium text-content-secondary shrink-0 w-28 pt-1">Product thumbnail:</label>
|
|
||||||
<div className="flex flex-col gap-2 min-w-[280px]">
|
|
||||||
{priorityList.map((entry, i) => (
|
|
||||||
<div
|
|
||||||
key={entry + i}
|
|
||||||
className={`flex items-center gap-2 border rounded-lg px-3 py-2 ${entryColor(entry)}`}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-mono text-content-muted w-4 shrink-0">{i + 1}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-sm font-medium truncate block">{entryLabel(entry)}</span>
|
|
||||||
{entry !== 'cad_thumbnail' && entry !== 'latest_render' && (
|
|
||||||
<span className="text-xs text-content-muted">newest completed render</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
disabled={i === 0 || updateSettingsMut.isPending}
|
|
||||||
onClick={() => moveUp(i)}
|
|
||||||
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
|
|
||||||
title="Move up"
|
|
||||||
>
|
|
||||||
<ChevronUp size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={i === priorityList.length - 1 || updateSettingsMut.isPending}
|
|
||||||
onClick={() => moveDown(i)}
|
|
||||||
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
|
|
||||||
title="Move down"
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={updateSettingsMut.isPending}
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
className="p-0.5 rounded hover:bg-status-error-bg text-content-muted hover:text-red-600"
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{addableOptions.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={priorityNewEntry}
|
|
||||||
onChange={(e) => setPriorityNewEntry(e.target.value)}
|
|
||||||
className="flex-1 px-2 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="">Add entry...</option>
|
|
||||||
{addableOptions.map((v) => (
|
|
||||||
<option key={v} value={v}>{entryLabel(v)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
disabled={!priorityNewEntry || updateSettingsMut.isPending}
|
|
||||||
onClick={addEntry}
|
|
||||||
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus size={13} /> Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-content-muted">
|
|
||||||
Sources are tried top to bottom. For specific output types, the <span className="font-medium">newest completed render</span> of that type is used. "CAD Thumbnail" always matches and stops the search.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>{/* end Output */}
|
|
||||||
|
|
||||||
{/* ── Service Status ─────────────────────────────────────── */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className={`rounded-lg border p-3 flex items-start gap-2.5 ${
|
|
||||||
info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{info.available
|
|
||||||
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" />
|
|
||||||
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
|
|
||||||
}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold text-content capitalize">{name}</p>
|
|
||||||
<p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!rendererStatus && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-content-muted p-2">
|
|
||||||
<Clock size={13} /> Checking service status...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,7 +695,7 @@ export default function AdminPage() {
|
|||||||
useCase: 'Quick checks, large assemblies',
|
useCase: 'Quick checks, large assemblies',
|
||||||
color: 'border-amber-400',
|
color: 'border-amber-400',
|
||||||
activeColor: 'border-amber-500 ring-2 ring-amber-200',
|
activeColor: 'border-amber-500 ring-2 ring-amber-200',
|
||||||
values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3, render_linear_deflection: 0.05, render_angular_deflection: 0.1 },
|
values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Standard',
|
label: 'Standard',
|
||||||
@@ -936,7 +704,7 @@ export default function AdminPage() {
|
|||||||
useCase: 'Recommended for most parts',
|
useCase: 'Recommended for most parts',
|
||||||
color: 'border-blue-400',
|
color: 'border-blue-400',
|
||||||
activeColor: 'border-blue-500 ring-2 ring-blue-200',
|
activeColor: 'border-blue-500 ring-2 ring-blue-200',
|
||||||
values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1, render_linear_deflection: 0.03, render_angular_deflection: 0.05 },
|
values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Fine',
|
label: 'Fine',
|
||||||
@@ -945,7 +713,7 @@ export default function AdminPage() {
|
|||||||
useCase: 'Close-up renders, small precision parts',
|
useCase: 'Close-up renders, small precision parts',
|
||||||
color: 'border-emerald-400',
|
color: 'border-emerald-400',
|
||||||
activeColor: 'border-emerald-500 ring-2 ring-emerald-200',
|
activeColor: 'border-emerald-500 ring-2 ring-emerald-200',
|
||||||
values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05, render_linear_deflection: 0.01, render_angular_deflection: 0.02 },
|
values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Ultra',
|
label: 'Ultra',
|
||||||
@@ -954,14 +722,12 @@ export default function AdminPage() {
|
|||||||
useCase: 'Marketing renders, extreme close-ups',
|
useCase: 'Marketing renders, extreme close-ups',
|
||||||
color: 'border-purple-400',
|
color: 'border-purple-400',
|
||||||
activeColor: 'border-purple-500 ring-2 ring-purple-200',
|
activeColor: 'border-purple-500 ring-2 ring-purple-200',
|
||||||
values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02, render_linear_deflection: 0.005, render_angular_deflection: 0.01 },
|
values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02 },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const isActive = (preset: typeof PRESETS[0]) =>
|
const isActive = (preset: typeof PRESETS[0]) =>
|
||||||
tess.scene_linear_deflection === preset.values.scene_linear_deflection &&
|
tess.scene_linear_deflection === preset.values.scene_linear_deflection &&
|
||||||
tess.scene_angular_deflection === preset.values.scene_angular_deflection &&
|
tess.scene_angular_deflection === preset.values.scene_angular_deflection
|
||||||
tess.render_linear_deflection === preset.values.render_linear_deflection &&
|
|
||||||
tess.render_angular_deflection === preset.values.render_angular_deflection
|
|
||||||
const isCustom = !PRESETS.some(isActive)
|
const isCustom = !PRESETS.some(isActive)
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -1041,70 +807,36 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Manual inputs */}
|
{/* Manual inputs */}
|
||||||
{showAdvancedTess && (<>
|
{showAdvancedTess && (<>
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div>
|
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Scene Deflection</p>
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">3D Viewer + USD Master</p>
|
<p className="text-xs text-content-muted mt-0.5">Controls mesh quality for the 3D viewer GLB, USD scene, and Blender renders.</p>
|
||||||
<p className="text-xs text-content-muted mt-0.5">Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.001"
|
|
||||||
max="10"
|
|
||||||
value={tess.scene_linear_deflection ?? 0.1}
|
|
||||||
onChange={e => setTessellationDraft(d => ({ ...d, scene_linear_deflection: parseFloat(e.target.value) }))}
|
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-content-muted">mm</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
max="1.5"
|
|
||||||
value={tess.scene_angular_deflection ?? 0.1}
|
|
||||||
onChange={e => setTessellationDraft(d => ({ ...d, scene_angular_deflection: parseFloat(e.target.value) }))}
|
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-content-muted">rad</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Blender Render Output</p>
|
<input
|
||||||
<p className="text-xs text-content-muted mt-0.5">Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.</p>
|
type="number"
|
||||||
</div>
|
step="0.01"
|
||||||
<div className="flex items-center gap-3">
|
min="0.001"
|
||||||
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
max="10"
|
||||||
<input
|
value={tess.scene_linear_deflection ?? 0.1}
|
||||||
type="number"
|
onChange={e => setTessellationDraft(d => ({ ...d, scene_linear_deflection: parseFloat(e.target.value) }))}
|
||||||
step="0.005"
|
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||||
min="0.001"
|
/>
|
||||||
max="10"
|
<span className="text-sm text-content-muted">mm</span>
|
||||||
value={tess.render_linear_deflection ?? 0.03}
|
</div>
|
||||||
onChange={e => setTessellationDraft(d => ({ ...d, render_linear_deflection: parseFloat(e.target.value) }))}
|
<div className="flex items-center gap-3">
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
||||||
/>
|
<input
|
||||||
<span className="text-sm text-content-muted">mm</span>
|
type="number"
|
||||||
</div>
|
step="0.01"
|
||||||
<div className="flex items-center gap-3">
|
min="0.01"
|
||||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
max="1.5"
|
||||||
<input
|
value={tess.scene_angular_deflection ?? 0.1}
|
||||||
type="number"
|
onChange={e => setTessellationDraft(d => ({ ...d, scene_angular_deflection: parseFloat(e.target.value) }))}
|
||||||
step="0.005"
|
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
||||||
min="0.005"
|
/>
|
||||||
max="1.5"
|
<span className="text-sm text-content-muted">rad</span>
|
||||||
value={tess.render_angular_deflection ?? 0.05}
|
|
||||||
onChange={e => setTessellationDraft(d => ({ ...d, render_angular_deflection: parseFloat(e.target.value) }))}
|
|
||||||
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-content-muted">rad</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
@@ -1124,51 +856,16 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 3D Viewer & GLB Export ────────────────────────────────────── */}
|
{/* ── 3D Viewer ────────────────────────────────────────────────── */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Eye size={18} className="text-accent" />
|
<Eye size={18} className="text-accent" />
|
||||||
<h2 className="text-base font-semibold text-content">3D Viewer & GLB Export</h2>
|
<h2 className="text-base font-semibold text-content">3D Viewer</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-content-muted mb-4">Settings for the interactive 3D viewer and GLB geometry export pipeline.</p>
|
<p className="text-sm text-content-muted mb-4">Camera zoom limits for the interactive 3D viewer.</p>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Scale Factor */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
|
||||||
GLB Scale Factor (mm to m)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.0001"
|
|
||||||
min="0.0001"
|
|
||||||
max="1"
|
|
||||||
value={viewer3d.gltf_scale_factor ?? 0.001}
|
|
||||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))}
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-muted mt-0.5">Default 0.001 converts mm to meters</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
|
||||||
Smooth Normals
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={viewer3d.gltf_smooth_normals ?? true}
|
|
||||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_smooth_normals: e.target.checked }))}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-content">Apply Laplacian smoothing on export</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-content-muted mt-1">Smooths surface normals during GLB export for a less faceted look in the 3D viewer.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Camera / Zoom Limits */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
<label className="text-sm font-medium text-content-muted block mb-1">
|
||||||
@@ -1204,57 +901,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBR Material Quality */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
|
||||||
GLB Material Mode
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={viewer3d.gltf_material_quality ?? 'pbr_colors'}
|
|
||||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_material_quality: e.target.value }))}
|
|
||||||
title="Controls what material data is embedded in exported GLB files. 'None' exports bare geometry; 'PBR Colors' bakes part colours into PBR materials."
|
|
||||||
className="input w-full"
|
|
||||||
>
|
|
||||||
<option value="none">None (geometry only)</option>
|
|
||||||
<option value="pbr_colors">PBR Colors (from part colors)</option>
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-content-muted mt-1">Material data embedded in exported GLB files.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
|
||||||
PBR Roughness (0-1)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.05"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
value={viewer3d.gltf_pbr_roughness ?? 0.4}
|
|
||||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))}
|
|
||||||
title="Surface roughness for GLB PBR materials (0 = mirror-smooth, 1 = fully matte). Default: 0.4 -- appropriate for brushed metal."
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-muted mt-1">0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
|
||||||
PBR Metallic (0-1)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.05"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
value={viewer3d.gltf_pbr_metallic ?? 0.6}
|
|
||||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))}
|
|
||||||
title="Metallic factor for GLB PBR materials (0 = dielectric/plastic, 1 = fully metallic). Default: 0.6 -- suitable for steel parts."
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-muted mt-1">0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1264,7 +910,7 @@ export default function AdminPage() {
|
|||||||
disabled={Object.keys(viewerDraft).length === 0 || updateSettingsMut.isPending}
|
disabled={Object.keys(viewerDraft).length === 0 || updateSettingsMut.isPending}
|
||||||
className="btn-primary disabled:opacity-40"
|
className="btn-primary disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Save 3D Settings
|
Save 3D Viewer Settings
|
||||||
</button>
|
</button>
|
||||||
{Object.keys(viewerDraft).length > 0 && (
|
{Object.keys(viewerDraft).length > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -1434,6 +1080,65 @@ export default function AdminPage() {
|
|||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
{activeTab === 'system' && isAdmin && <>
|
{activeTab === 'system' && isAdmin && <>
|
||||||
|
|
||||||
|
{/* ── Blender Status ─────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Cpu size={18} className="text-accent" />
|
||||||
|
<h2 className="text-base font-semibold text-content">Render Worker Status</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-content-muted mb-4">Checks Blender availability on the render-worker container via Celery.</p>
|
||||||
|
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{blenderStatus ? (
|
||||||
|
blenderStatus.available ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-status-success-bg text-status-success-text">
|
||||||
|
<CheckCircle2 size={12} /> Blender Available
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-status-error-bg text-status-error-text">
|
||||||
|
<XCircle size={12} /> Blender Unavailable
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-content-muted">Not checked yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchBlenderStatus()}
|
||||||
|
disabled={blenderStatusFetching}
|
||||||
|
className="btn-secondary text-sm flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={blenderStatusFetching ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{blenderStatus && (
|
||||||
|
<div className="bg-surface-alt rounded-md p-4 space-y-2">
|
||||||
|
{blenderStatus.version && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Version</span>
|
||||||
|
<span className="text-xs text-content font-mono">{blenderStatus.version}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blenderStatus.blender_bin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Binary</span>
|
||||||
|
<span className="text-xs text-content font-mono">{blenderStatus.blender_bin}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blenderStatus.error && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Error</span>
|
||||||
|
<span className="text-xs text-status-error-text font-mono">{blenderStatus.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Reprocessing ──────────────────────────────────────────────── */}
|
{/* ── Reprocessing ──────────────────────────────────────────────── */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user