diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index 5356d3f..c7e52fb 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -723,20 +723,14 @@ async def seed_workflows( async def renderer_status( admin: User = Depends(require_global_admin), ): - """Check health of renderer services.""" - from app.services.render_blender import find_blender, is_blender_available - blender_available = is_blender_available() - blender_bin = find_blender() - return { - "blender": { - "available": blender_available, - "note": ( - f"render-worker subprocess ({blender_bin})" - if blender_available - else "Blender not found — check render-worker container and BLENDER_BIN" - ), - }, - } + """Check Blender availability on the render-worker via Celery task.""" + from app.tasks.gpu_tasks import check_blender_status + try: + result = check_blender_status.apply_async() + data = result.get(timeout=10) + except Exception as exc: + data = {"available": False, "blender_bin": "", "version": "", "error": str(exc)} + return {"blender": data} @router.post("/import-media-assets") diff --git a/backend/app/tasks/gpu_tasks.py b/backend/app/tasks/gpu_tasks.py index 394512d..7abbb99 100644 --- a/backend/app/tasks/gpu_tasks.py +++ b/backend/app/tasks/gpu_tasks.py @@ -5,6 +5,26 @@ from app.tasks.celery_app import celery_app 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") def probe_gpu() -> dict: """Run Blender GPU probe on the render-worker. Stores result in system_settings.""" diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index d741dbe..e09b985 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState, useRef } from 'react' 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 api from '../api/client' import ConfirmModal from '../components/ConfirmModal' @@ -33,7 +33,6 @@ export default function AdminPage() { const [editingUserId, setEditingUserId] = useState(null) const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true }) const [editingTemplateId, setEditingTemplateId] = useState(null) - const [priorityNewEntry, setPriorityNewEntry] = useState('') const { data: users } = useQuery({ queryKey: ['admin-users'], @@ -88,12 +87,8 @@ export default function AdminPage() { blender_cycles_samples: number blender_eevee_samples: number threejs_render_size: number - thumbnail_format: string blender_smooth_angle: number cycles_device: string - blender_max_concurrent_renders: number - render_stall_timeout_minutes: number - product_thumbnail_priority: string // JSON array render_backend: string smtp_enabled: boolean smtp_host: string @@ -101,17 +96,10 @@ export default function AdminPage() { smtp_user: string smtp_password: string smtp_from_address: string - gltf_scale_factor: number - gltf_smooth_normals: boolean viewer_max_distance: number viewer_min_distance: number - gltf_material_quality: string - gltf_pbr_roughness: number - gltf_pbr_metallic: number scene_linear_deflection: number scene_angular_deflection: number - render_linear_deflection: number - render_angular_deflection: number tessellation_engine: string } @@ -140,15 +128,6 @@ export default function AdminPage() { const tess = { ...settings, ...tessellationDraft } as Settings 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 - }, - refetchInterval: 30000, - }) - const updateSettingsMut = useMutation({ mutationFn: (data: Partial) => api.put('/admin/settings', data), onSuccess: () => { @@ -325,6 +304,17 @@ export default function AdminPage() { type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system' const [activeTab, setActiveTab] = useState('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 = Object.keys(blenderDraft).length > 0 || Object.keys(viewerDraft).length > 0 || @@ -549,17 +539,6 @@ export default function AdminPage() {

Render engine selection, sample counts, smooth angle, and performance tuning for Blender 5.

-
- Service Status - -
-
{/* ── Render Quality ─────────────────────────────────────── */}
@@ -681,41 +660,6 @@ export default function AdminPage() {
- {/* ── Performance ────────────────────────────────────────── */} -
-

Performance

-
-
- Max concurrent - 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" - /> -

- Max parallel Blender render jobs (1-16). Higher values use more RAM (~400 MB each). Applied live without restart. -

-
-
- Stall timeout - 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" - /> -

- Minutes before a stuck render job is auto-restarted (10-10080). Checked every 5 min by the watchdog. -

-
-
-
- {/* Save button */} {Object.keys(blenderDraft).length > 0 && ( - ))} -

- {settings?.thumbnail_format === 'jpg' - ? 'JPEG -- ~3-5x smaller files, minimal quality loss at 92% quality.' - : 'PNG -- lossless, larger files.'} -

-
- - {/* 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 ( -
- -
- {priorityList.map((entry, i) => ( -
- {i + 1} -
- {entryLabel(entry)} - {entry !== 'cad_thumbnail' && entry !== 'latest_render' && ( - newest completed render - )} -
- - - -
- ))} - - {addableOptions.length > 0 && ( -
- - -
- )} - -

- Sources are tried top to bottom. For specific output types, the newest completed render of that type is used. "CAD Thumbnail" always matches and stops the search. -

-
-
- ) - })()} - {/* end Output */} - - {/* ── Service Status ─────────────────────────────────────── */} -
-

Service Status

-
- {rendererStatus && Object.entries(rendererStatus).map(([name, info]) => ( -
- {info.available - ? - : - } -
-

{name}

-

{info.note || (info.available ? 'Online' : 'Offline')}

-
-
- ))} - {!rendererStatus && ( -
- Checking service status... -
- )} -
-
@@ -927,7 +695,7 @@ export default function AdminPage() { useCase: 'Quick checks, large assemblies', color: 'border-amber-400', 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', @@ -936,7 +704,7 @@ export default function AdminPage() { useCase: 'Recommended for most parts', color: 'border-blue-400', 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', @@ -945,7 +713,7 @@ export default function AdminPage() { useCase: 'Close-up renders, small precision parts', color: 'border-emerald-400', 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', @@ -954,14 +722,12 @@ export default function AdminPage() { useCase: 'Marketing renders, extreme close-ups', color: 'border-purple-400', 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]) => tess.scene_linear_deflection === preset.values.scene_linear_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 + tess.scene_angular_deflection === preset.values.scene_angular_deflection const isCustom = !PRESETS.some(isActive) return (
@@ -1041,70 +807,36 @@ export default function AdminPage() { {/* Manual inputs */} {showAdvancedTess && (<> -
-
-
-

3D Viewer + USD Master

-

Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.

-
-
- - 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" - /> - mm -
-
- - 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" - /> - rad -
+
+
+

Scene Deflection

+

Controls mesh quality for the 3D viewer GLB, USD scene, and Blender renders.

-
-
-

Blender Render Output

-

Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.

-
-
- - setTessellationDraft(d => ({ ...d, render_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" - /> - mm -
-
- - 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" - /> - rad -
+
+ + 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" + /> + mm +
+
+ + 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" + /> + rad
)} @@ -1124,51 +856,16 @@ export default function AdminPage() {
- {/* ── 3D Viewer & GLB Export ────────────────────────────────────── */} + {/* ── 3D Viewer ────────────────────────────────────────────────── */}
-

3D Viewer & GLB Export

+

3D Viewer

-

Settings for the interactive 3D viewer and GLB geometry export pipeline.

+

Camera zoom limits for the interactive 3D viewer.

- {/* Scale Factor */} -
-
- - setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))} - className="input w-full" - /> -

Default 0.001 converts mm to meters

-
-
- - -

Smooths surface normals during GLB export for a less faceted look in the 3D viewer.

-
-
- - {/* Camera / Zoom Limits */}
- {/* PBR Material Quality */} -
-
- - -

Material data embedded in exported GLB files.

-
-
- - 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" - /> -

0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.

-
-
- - 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" - /> -

0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.

-
-
-
{Object.keys(viewerDraft).length > 0 && ( +
+ {blenderStatus && ( +
+ {blenderStatus.version && ( +
+ Version + {blenderStatus.version} +
+ )} + {blenderStatus.blender_bin && ( +
+ Binary + {blenderStatus.blender_bin} +
+ )} + {blenderStatus.error && ( +
+ Error + {blenderStatus.error} +
+ )} +
+ )} +
+
+ {/* ── Reprocessing ──────────────────────────────────────────────── */}