From cfaf362f2302ea51269b3d95d78f6a96cc0b06b5 Mon Sep 17 00:00:00 2001 From: Bryandero98 Date: Thu, 3 Sep 2026 17:58:38 -0500 Subject: [PATCH] feat: add RigPanel UI to drive a retarget (clip picker, diagnostic preview, write handoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retarget lane shipped server-side in #5893 (POST /api/rigging/models/:id/retarget, GET /api/rigging/clips) but had no UI — the only way to animate a rigged character was to call the endpoint by hand. - apiRigging.js: adds listRiggingClips() and retargetImageTo3dModel(), mirroring the existing getRiggingReadiness()/rigImageTo3dModel() contract (updated model record on success, the server's measured sentence surfaced verbatim on refusal). - RigPanel.jsx: once a rig is ready, fetches the clip library (explaining an empty roster rather than rendering a dead picker), runs a diagnostic preview showing the proposed head-zone cleanup vs its cap and the motion numbers, and offers an explicit "Apply cleanup" write action — refused-with-reason instead of offered when the proposal is over cap. The published animation's download link appears once a write run lands, same pattern as the existing rig section. Refs #6065 --- client/src/components/media/RigPanel.jsx | 148 +++++++++++++++++- client/src/components/media/RigPanel.test.jsx | 145 ++++++++++++++++- client/src/services/README.md | 2 +- client/src/services/apiRigging.js | 16 ++ 4 files changed, 308 insertions(+), 3 deletions(-) diff --git a/client/src/components/media/RigPanel.jsx b/client/src/components/media/RigPanel.jsx index d533bad81b..8dc120c878 100644 --- a/client/src/components/media/RigPanel.jsx +++ b/client/src/components/media/RigPanel.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { AlertTriangle, Bone, Loader2 } from 'lucide-react'; -import { getRiggingReadiness, rigImageTo3dModel } from '../../services/api'; +import { getRiggingReadiness, rigImageTo3dModel, listRiggingClips, retargetImageTo3dModel } from '../../services/api'; import { riggingReasonLabel } from '../../lib/riggingReasons.js'; import useMounted from '../../hooks/useMounted'; import { useInstanceFeatures } from '../../hooks/useInstanceFeatures'; @@ -27,6 +27,11 @@ export default function RigPanel({ record, onRecordChange }) { const enabled = isFeatureEnabled('rigging'); const [readiness, setReadiness] = useState(null); const [busy, setBusy] = useState(false); + // Retarget lane (#6065). `clips === null` means "not fetched yet", distinct from + // an empty roster — the empty state gets an explaining line, not a dead picker. + const [clips, setClips] = useState(null); + const [selectedClip, setSelectedClip] = useState(''); + const [retargeting, setRetargeting] = useState(false); useEffect(() => { if (!enabled) return; @@ -39,6 +44,18 @@ export default function RigPanel({ record, onRecordChange }) { return () => { active = false; }; }, [enabled]); + // The clip library is only relevant once a rig exists to animate. Fetched from + // `record.rig` directly (not the `rig` const below, which is declared after the + // early return) so this hook stays unconditional per the Rules of Hooks. + useEffect(() => { + if (!enabled || record.rig?.status !== 'ready') return; + let active = true; + listRiggingClips({ silent: true }) + .then((value) => { if (active) setClips(value?.clips || []); }) + .catch(() => { if (active) setClips([]); }); + return () => { active = false; }; + }, [enabled, record.rig?.status]); + const handleRig = useCallback(async () => { if (busy) return; setBusy(true); @@ -54,6 +71,33 @@ export default function RigPanel({ record, onRecordChange }) { } }, [busy, record.id, onRecordChange, mountedRef]); + const runRetarget = useCallback(async (mode, clip) => { + if (retargeting) return; + setRetargeting(true); + const next = await retargetImageTo3dModel(record.id, { clip, mode }, { silent: true }).catch((err) => { + // The gate's sentence IS the error message — surface it verbatim, same + // contract as the rig gate above. + toast.error(err?.message || 'Retarget failed.'); + return null; + }); + if (mountedRef.current) setRetargeting(false); + if (next && mountedRef.current) { + onRecordChange(next); + toast.success(mode === 'write' ? 'Animation applied.' : 'Retarget preview ready.'); + } + }, [retargeting, record.id, onRecordChange, mountedRef]); + + const handlePreviewRetarget = useCallback(() => { + if (!selectedClip) return; + runRetarget('diagnostic', selectedClip); + }, [selectedClip, runRetarget]); + + const handleApplyCleanup = useCallback(() => { + const clip = record.retarget?.clipFile; + if (!clip) return; + runRetarget('write', clip); + }, [record.retarget?.clipFile, runRetarget]); + if (!enabled) return null; const rig = record.rig || null; @@ -61,7 +105,17 @@ export default function RigPanel({ record, onRecordChange }) { const canRig = record.status === 'ready' && Boolean(record.assetPath) && readiness?.ready === true; const blockedReason = readiness && !readiness.ready ? riggingReasonLabel(readiness.reason) : null; + const retarget = record.retarget || null; + const canRetarget = rig?.status === 'ready'; + const clipsLoaded = clips !== null; + const hasClips = clipsLoaded && clips.length > 0; + const retargetBusy = retargeting || retarget?.status === 'retargeting'; + const diagnosticReady = retarget?.status === 'ready' && retarget.mode === 'diagnostic'; + const writeReady = retarget?.status === 'ready' && retarget.mode === 'write'; + const overCap = diagnosticReady && Boolean(retarget.summary?.cleanupOverCap); + return ( + <>
@@ -123,5 +177,97 @@ export default function RigPanel({ record, onRecordChange }) {

)}
+ + {canRetarget && ( +
+
+ +

Animate with a clip

+
+ + {!clipsLoaded &&

Checking the clip library…

} + {clipsLoaded && !hasClips && ( +

+ No animation clips yet. Drop a GLB clip into the clip library to animate this character. +

+ )} + + {hasClips && ( +
+ + +
+ )} + + {retarget?.status === 'failed' && retarget.error && ( +
+ + {retarget.error} +
+ )} + + {diagnosticReady && retarget.summary && ( +
+

+ Clip "{retarget.summary.clip}" ({(retarget.summary.clipDuration ?? 0).toFixed(2)}s) — proposed + cleanup {retarget.summary.proposedCleanupVertices ?? 0} of{' '} + {retarget.summary.cleanupCapVertices ?? 0} vertex cap. +

+

+ Motion check: {retarget.summary.sampledFrames ?? 0} sampled frames, max joint move{' '} + {(retarget.summary.maxJointTranslation ?? 0).toExponential(2)} units. +

+ {overCap ? ( +

+ This cleanup is over cap and cannot be applied — try a different clip. +

+ ) : ( + + )} +
+ )} + + {writeReady && retarget.assetPath && ( +
+

Animated with "{retarget.summary?.clip}".

+ + Download animated .glb + +
+ )} +
+ )} + ); } diff --git a/client/src/components/media/RigPanel.test.jsx b/client/src/components/media/RigPanel.test.jsx index c5fcf1f282..aa54525638 100644 --- a/client/src/components/media/RigPanel.test.jsx +++ b/client/src/components/media/RigPanel.test.jsx @@ -1,7 +1,12 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -const mock = vi.hoisted(() => ({ getRiggingReadiness: vi.fn(), rigImageTo3dModel: vi.fn() })); +const mock = vi.hoisted(() => ({ + getRiggingReadiness: vi.fn(), + rigImageTo3dModel: vi.fn(), + listRiggingClips: vi.fn(), + retargetImageTo3dModel: vi.fn(), +})); vi.mock('../../services/api', () => mock); const features = vi.hoisted(() => ({ enabled: true })); @@ -16,12 +21,22 @@ import RigPanel from './RigPanel'; const READY_RECORD = { id: 'image3d-example', status: 'ready', assetPath: '/data/image-to-3d/image3d-example/model.glb' }; const RUNTIME_READY = { ready: true, reason: null }; +const RIGGED_RECORD = { + ...READY_RECORD, + rig: { + status: 'ready', + assetPath: '/data/image-to-3d/image3d-example/rig/rig-example/character.rigged.glb', + bytes: 2_400_000, + summary: { vertices: 10000, bones: 17, unweightedFractionAfterHeat: 0.002, nearestBoneCompleted: 20, unweightedCeiling: 0.005 }, + }, +}; describe('RigPanel', () => { beforeEach(() => { vi.clearAllMocks(); features.enabled = true; mock.getRiggingReadiness.mockResolvedValue(RUNTIME_READY); + mock.listRiggingClips.mockResolvedValue({ clips: [] }); }); it('stays out of the page entirely when the rigging feature is off', () => { @@ -66,6 +81,9 @@ describe('RigPanel', () => { expect(screen.getByText(/Rigged against 17 bones/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /download rigged/i })) .toHaveAttribute('href', rigged.rig.assetPath); + + // A ready rig also mounts the retarget lane's clip fetch (#6065) — let it settle. + await screen.findByText(/no animation clips yet/i); }); it('shows the measured sentence a refused rig came back with, not a generic error', async () => { @@ -81,3 +99,128 @@ describe('RigPanel', () => { expect(await screen.findByText(/4\.2% of 10000 vertices unweighted, ceiling is 0\.5%/)).toBeInTheDocument(); }); }); + +// #6065 +describe('RigPanel retarget lane', () => { + beforeEach(() => { + vi.clearAllMocks(); + features.enabled = true; + mock.getRiggingReadiness.mockResolvedValue(RUNTIME_READY); + }); + + it('explains an empty clip library instead of rendering a dead picker', async () => { + mock.listRiggingClips.mockResolvedValue({ clips: [] }); + render( {}} />); + + expect(await screen.findByText(/no animation clips yet/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/animation clip/i)).not.toBeInTheDocument(); + }); + + it('runs a diagnostic preview and shows the proposed cleanup without offering write yet', async () => { + mock.listRiggingClips.mockResolvedValue({ clips: [{ filename: 'wave.glb', label: 'Wave' }] }); + const diagnosed = { + ...RIGGED_RECORD, + retarget: { + status: 'ready', + mode: 'diagnostic', + clipFile: 'wave.glb', + assetPath: '/data/image-to-3d/image3d-example/retarget/r1/character.animated.glb', + summary: { + clip: 'Wave', clipDuration: 1.5, proposedCleanupVertices: 40, changedCleanupVertices: 0, + cleanupCapVertices: 240, cleanupOverCap: false, sampledFrames: 8, maxJointTranslation: 0.12, + }, + }, + }; + mock.retargetImageTo3dModel.mockResolvedValue(diagnosed); + const onRecordChange = vi.fn(); + const { rerender } = render(); + + const select = await screen.findByLabelText(/animation clip/i); + fireEvent.change(select, { target: { value: 'wave.glb' } }); + fireEvent.click(screen.getByRole('button', { name: /preview retarget/i })); + + await waitFor(() => expect(onRecordChange).toHaveBeenCalledWith(diagnosed)); + expect(mock.retargetImageTo3dModel) + .toHaveBeenCalledWith('image3d-example', { clip: 'wave.glb', mode: 'diagnostic' }, { silent: true }); + + rerender(); + + // Nothing was written: no "animated" download link, only the measured proposal + // plus a follow-up write action. + expect(screen.queryByRole('link', { name: /download animated/i })).not.toBeInTheDocument(); + expect(screen.getByText(/proposed.*cleanup 40 of 240 vertex cap/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /apply cleanup/i })).toBeEnabled(); + }); + + it('refuses the write action with a reason instead of offering it when the proposal is over cap', async () => { + mock.listRiggingClips.mockResolvedValue({ clips: [{ filename: 'wave.glb', label: 'Wave' }] }); + const overCap = { + ...RIGGED_RECORD, + retarget: { + status: 'ready', + mode: 'diagnostic', + clipFile: 'wave.glb', + summary: { + clip: 'Wave', clipDuration: 1.5, proposedCleanupVertices: 400, changedCleanupVertices: 0, + cleanupCapVertices: 240, cleanupOverCap: true, sampledFrames: 8, maxJointTranslation: 0.12, + }, + }, + }; + render( {}} />); + + expect(await screen.findByText(/over cap and cannot be applied/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /apply cleanup/i })).not.toBeInTheDocument(); + }); + + it('applies the write run from a diagnostic result and shows the published animation', async () => { + mock.listRiggingClips.mockResolvedValue({ clips: [{ filename: 'wave.glb', label: 'Wave' }] }); + const diagnosed = { + ...RIGGED_RECORD, + retarget: { + status: 'ready', + mode: 'diagnostic', + clipFile: 'wave.glb', + summary: { + clip: 'Wave', clipDuration: 1.5, proposedCleanupVertices: 40, changedCleanupVertices: 0, + cleanupCapVertices: 240, cleanupOverCap: false, sampledFrames: 8, maxJointTranslation: 0.12, + }, + }, + }; + const written = { + ...RIGGED_RECORD, + retarget: { + ...diagnosed.retarget, + mode: 'write', + assetPath: '/data/image-to-3d/image3d-example/retarget/r1/character.animated.glb', + }, + }; + mock.retargetImageTo3dModel.mockResolvedValue(written); + const onRecordChange = vi.fn(); + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: /apply cleanup/i })); + + await waitFor(() => expect(onRecordChange).toHaveBeenCalledWith(written)); + expect(mock.retargetImageTo3dModel) + .toHaveBeenCalledWith('image3d-example', { clip: 'wave.glb', mode: 'write' }, { silent: true }); + + rerender(); + expect(screen.getByRole('link', { name: /download animated/i })) + .toHaveAttribute('href', written.retarget.assetPath); + }); + + it('shows a gate refusal as the server\'s own sentence', async () => { + mock.listRiggingClips.mockResolvedValue({ clips: [{ filename: 'wave.glb', label: 'Wave' }] }); + const failed = { + ...RIGGED_RECORD, + retarget: { + status: 'failed', + clipFile: 'wave.glb', + mode: 'diagnostic', + error: 'The clip and this character do not share a complete skeleton: 3 bones could not be matched (LeftHand, RightHand, Spine2).', + }, + }; + render( {}} />); + expect(await screen.findByText(/3 bones could not be matched \(LeftHand, RightHand, Spine2\)/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/services/README.md b/client/src/services/README.md index 41494b1cca..3863dd4c8b 100644 --- a/client/src/services/README.md +++ b/client/src/services/README.md @@ -150,5 +150,5 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire | `uiInteract.js` | Execute voice `ui_click` / `ui_fill` / `ui_select` against live DOM. | | `domIndex.js` | DOM indexer for voice accessibility mode. | | `staleBuildToast.jsx` | Sticky toast shown when server's build id differs from client's. | -| `apiRigging.js` | Character rigging. `getRiggingReadiness()` (`GET /rigging/readiness`): whether this install's Blender runtime is provisioned, the resolved interpreter, the module version, the install command when it is not, and the auto-skin threshold defaults. `rigImageTo3dModel(id, body)` (`POST /rigging/models/:id`): auto-skin a rendered mesh behind the measured weight-coverage gate, resolving with the updated model record. | +| `apiRigging.js` | Character rigging. `getRiggingReadiness()` (`GET /rigging/readiness`): whether this install's Blender runtime is provisioned, the resolved interpreter, the module version, the install command when it is not, and the auto-skin threshold defaults. `rigImageTo3dModel(id, body)` (`POST /rigging/models/:id`): auto-skin a rendered mesh behind the measured weight-coverage gate, resolving with the updated model record. `listRiggingClips()` (`GET /rigging/clips`): the locally-held animation clip library plus CoS-state coverage. `retargetImageTo3dModel(id, body)` (`POST /rigging/models/:id/retarget`): apply a clip to a published rig in `diagnostic` (measure only) or `write` mode, resolving with the updated model record. | | `apiAvatar.js` | Avatar surfaces. `getRiggedAvatars()` (`GET /avatar/rigged`): the install's verified animated records, each with its `?variant=` spelling, serving URL, retargeted clip name, and server-computed CoS-state coverage. | diff --git a/client/src/services/apiRigging.js b/client/src/services/apiRigging.js index d55c95b35e..52f73fb771 100644 --- a/client/src/services/apiRigging.js +++ b/client/src/services/apiRigging.js @@ -18,3 +18,19 @@ export const rigImageTo3dModel = (id, input = {}, options) => body: JSON.stringify(input), ...options, }); + +// The animation clips this install has locally (user-dropped GLB files), plus which +// CoS states they cover. Read-only and cheap — safe to call on every rigged-record view. +export const listRiggingClips = (options) => request('/rigging/clips', options); + +// Retarget one locally-held clip onto a model's published rig. `mode: 'diagnostic'` +// (the server default) measures the proposed head-zone cleanup and motion without +// writing anything; `mode: 'write'` applies the same proposal and may refuse if it is +// over cap. Resolves with the updated model record — a refusal arrives as an error +// carrying the server's measured sentence, same contract as `rigImageTo3dModel`. +export const retargetImageTo3dModel = (id, input = {}, options) => + request(`/rigging/models/${encodeURIComponent(id)}/retarget`, { + method: 'POST', + body: JSON.stringify(input), + ...options, + });