Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 147 additions & 1 deletion client/src/components/media/RigPanel.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -54,14 +71,51 @@ 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;
const rigging = busy || rig?.status === 'rigging';
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 (
<>
<section className="mt-4 rounded-lg border border-port-border bg-port-card p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -123,5 +177,97 @@ export default function RigPanel({ record, onRecordChange }) {
</p>
)}
</section>

{canRetarget && (
<section className="mt-4 rounded-lg border border-port-border bg-port-card p-3">
<div className="flex items-center gap-2">
<Bone className="h-4 w-4 text-port-accent" />
<h2 className="text-sm font-semibold text-white">Animate with a clip</h2>
</div>

{!clipsLoaded && <p className="mt-2 text-xs text-gray-500">Checking the clip library…</p>}
{clipsLoaded && !hasClips && (
<p className="mt-2 text-xs text-gray-500">
No animation clips yet. Drop a GLB clip into the clip library to animate this character.
</p>
)}

{hasClips && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<select
aria-label="Animation clip"
value={selectedClip}
onChange={(e) => setSelectedClip(e.target.value)}
disabled={retargetBusy}
className="min-h-[44px] flex-1 rounded-md border border-port-border bg-port-bg px-2 py-1 text-xs text-white disabled:opacity-40"
>
<option value="">Select a clip…</option>
{clips.map((clip) => (
<option key={clip.filename} value={clip.filename}>{clip.label}</option>
))}
</select>
<button
type="button"
onClick={handlePreviewRetarget}
disabled={!selectedClip || retargetBusy}
title={!selectedClip ? 'Pick a clip first' : undefined}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-md border border-port-border px-3 py-1.5 text-xs text-gray-300 hover:border-port-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{retargetBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Bone className="h-3.5 w-3.5" />}
{retargetBusy ? 'Retargeting…' : 'Preview retarget'}
</button>
</div>
)}

{retarget?.status === 'failed' && retarget.error && (
<div className="mt-2 flex items-start gap-1.5 rounded-md border border-port-error/30 bg-port-error/10 px-3 py-2 text-xs text-port-error">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{retarget.error}</span>
</div>
)}

{diagnosticReady && retarget.summary && (
<div className="mt-2 space-y-1 text-xs text-gray-400">
<p>
Clip "{retarget.summary.clip}" ({(retarget.summary.clipDuration ?? 0).toFixed(2)}s) — proposed
cleanup {retarget.summary.proposedCleanupVertices ?? 0} of{' '}
{retarget.summary.cleanupCapVertices ?? 0} vertex cap.
</p>
<p>
Motion check: {retarget.summary.sampledFrames ?? 0} sampled frames, max joint move{' '}
{(retarget.summary.maxJointTranslation ?? 0).toExponential(2)} units.
</p>
{overCap ? (
<p className="text-port-warning">
This cleanup is over cap and cannot be applied — try a different clip.
</p>
) : (
<button
type="button"
onClick={handleApplyCleanup}
disabled={retargetBusy}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-md border border-port-accent px-3 py-1.5 text-xs text-port-accent hover:bg-port-accent/10 disabled:cursor-not-allowed disabled:opacity-40"
>
{retargetBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Bone className="h-3.5 w-3.5" />}
{retargetBusy ? 'Applying…' : 'Apply cleanup'}
</button>
)}
</div>
)}

{writeReady && retarget.assetPath && (
<div className="mt-2 space-y-1 text-xs text-gray-400">
<p className="text-port-success">Animated with "{retarget.summary?.clip}".</p>
<a
href={retarget.assetPath}
className="inline-block underline decoration-dotted hover:text-gray-200"
>
Download animated .glb
</a>
</div>
)}
</section>
)}
</>
);
}
145 changes: 144 additions & 1 deletion client/src/components/media/RigPanel.test.jsx
Original file line number Diff line number Diff line change
@@ -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 }));
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(<RigPanel record={RIGGED_RECORD} onRecordChange={() => {}} />);

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(<RigPanel record={RIGGED_RECORD} onRecordChange={onRecordChange} />);

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(<RigPanel record={diagnosed} onRecordChange={onRecordChange} />);

// 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(<RigPanel record={overCap} onRecordChange={() => {}} />);

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(<RigPanel record={diagnosed} onRecordChange={onRecordChange} />);

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(<RigPanel record={written} onRecordChange={onRecordChange} />);
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(<RigPanel record={failed} onRecordChange={() => {}} />);
expect(await screen.findByText(/3 bones could not be matched \(LeftHand, RightHand, Spine2\)/)).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion client/src/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
16 changes: 16 additions & 0 deletions client/src/services/apiRigging.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});