Files
opendance-web/web-server/admin/src/pages/Motions.tsx
T
opendance aad60cbd2e feat(motions): 动作预览——骨架缩略图 + 管理端 3D 回放(契约 data-models 0.6.0)
按《DSH 开发文档 v1.6》4.11(T-WEB-014,用户标注「重要」):

服务端:
- 抽出公共 PNG 编码器 src/common/png.ts(模型缩略图改为复用,零原生依赖不变)
- 新增 src/motions/motion-thumbnail.ts:关键点序列 -> 正视图骨架 PNG
  (复用 33 点 PARENT_TABLE 连线,包围盒按整段计算,低置信度点不画)
- motions 增 thumbnail_asset_id(迁移 AddMotionThumbnail)+ AssetKind.MotionThumbnail
- /preprocess 生成缩略图并落库,失败只记 warning;MotionDto 增 thumbnailUrl(契约 0.6.0)

管理端:
- 新增 MotionPreview:懒加载 NDJSON -> 骨架线框 + 关节实例球体
  支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环
  叠加帧号/当前时间/总时长,按整段包围盒自动取景
  three.js 单独分包(lazy + Suspense),主包 241 kB 不涨
- 动作列表新增 40px 骨架缩略图列(点击开预览)+ 预览按钮 + 内联预览面板
- 新增 admin/src/lib/keypoint-sequence.ts 与 lib/skeleton-topology.ts

测试:新增 8 项;规模 18 文件 / 237 项。版本 0.5.0。
2026-09-17 00:21:09 +08:00

571 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useState, type FormEvent } from 'react';
import { apiUrl, errorMessage } from '../api/client';
import {
deleteMotion,
listMotions,
listSongs,
preprocessMotion,
uploadMotion,
} from '../api/endpoints';
import type { Motion, PublishStatus, Song } from '../api/types';
import { MOTION_FORMATS } from '../api/types';
import { formatNumber } from '../lib/format';
import { MOTION_FORMAT_LABELS, PREPROCESS_LABELS, STATUS_OPTIONS } from '../lib/labels';
import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
import { useAsync } from '../lib/useAsync';
const MotionPreview = lazy(async () => {
const module = await import('../components/MotionPreview');
return { default: module.MotionPreview };
});
import {
ConfirmDialog,
Empty,
ErrorBox,
Field,
FormatBadge,
Loading,
Modal,
Notice,
Pagination,
SourceCell,
SourceFieldsForm,
StatusBadge,
type SourceFormValue,
} from '../components/ui';
const PAGE_SIZE = 20;
const ACCEPT_EXT = '.bvh,.vmd,.fbx,.gltf,.glb,.json';
interface MotionFilters {
q: string;
format: string;
status: PublishStatus | '';
page: number;
}
interface MotionFormState extends SourceFormValue {
name: string;
songId: string;
fps: string;
autoPreprocess: boolean;
status: PublishStatus;
}
const EMPTY_MOTION_FORM: MotionFormState = {
name: '',
songId: '',
fps: '',
autoPreprocess: true,
status: 'draft',
sourceType: '',
sourceName: '',
sourceUrl: '',
license: '',
author: '',
};
function validateMotionForm(form: MotionFormState): FieldErrors {
const errors: FieldErrors = {};
requireText(form.name, 'name', '动作名称', errors, 200);
const fps = parseOptionalNumber(form.fps);
if (fps !== undefined && (!Number.isFinite(fps) || fps <= 0 || fps > 240)) {
errors.fps = '帧率需为 0–240 之间的数字';
}
validateSourceFields(form, errors);
return errors;
}
function PreprocessBadge({ motion }: { motion: Motion }) {
const label = PREPROCESS_LABELS[motion.preprocessState] ?? motion.preprocessState;
const title =
motion.preprocessState === 'failed'
? motion.preprocessError ?? '预处理失败(无错误详情)'
: motion.preprocessState === 'done'
? `关键点帧数:${motion.keypointFrames ?? '—'} · 格式:${motion.keypointFormat ?? '—'}`
: `状态:${motion.preprocessState}`;
return (
<span className={`badge badge-pre-${motion.preprocessState}`} title={title}>
{label}
{motion.preprocessState === 'running' ? <span className="spinner spinner-xs" aria-hidden="true" /> : null}
</span>
);
}
/* ------------------------------------------------------------ 上传弹窗 */
function MotionUploadModal({
songs,
songsError,
onClose,
onSaved,
}: {
songs: Song[];
songsError: string | null;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [form, setForm] = useState<MotionFormState>(EMPTY_MOTION_FORM);
const [file, setFile] = useState<File | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [saving, setSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const patch = (part: Partial<MotionFormState>): void => {
setForm((previous) => ({ ...previous, ...part }));
};
const onSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
const nextErrors = validateMotionForm(form);
if (!file) nextErrors.file = '请选择动作文件';
setErrors(nextErrors);
if (hasErrors(nextErrors) || !file || form.sourceType === '') return;
setSaving(true);
setServerError(null);
try {
await uploadMotion({
file,
name: form.name.trim(),
songId: form.songId === '' ? undefined : Number(form.songId),
fps: parseOptionalNumber(form.fps),
autoPreprocess: form.autoPreprocess,
status: form.status,
sourceType: form.sourceType,
sourceName: form.sourceName.trim(),
sourceUrl: form.sourceUrl.trim(),
license: form.license.trim(),
author: form.author.trim(),
});
onSaved('动作已上传');
} catch (cause) {
setServerError(errorMessage(cause));
} finally {
setSaving(false);
}
};
return (
<Modal
title="上传动作"
size="xl"
onClose={onClose}
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
取消
</button>
<button type="submit" form="motion-upload-form" className="btn btn-primary" disabled={saving}>
{saving ? '上传中…' : '上传'}
</button>
</>
}
>
<form id="motion-upload-form" className="form" onSubmit={onSubmit}>
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
{songsError ? (
<Notice kind="info">曲目列表加载失败,关联曲目暂不可选:{songsError}</Notice>
) : null}
<div className="form-grid">
<Field label="动作文件 file" required error={errors.file} hint={`支持 ${ACCEPT_EXT}`}>
<input
type="file"
accept={ACCEPT_EXT}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</Field>
<Field label="动作名称 name" required error={errors.name}>
<input
type="text"
value={form.name}
maxLength={200}
onChange={(event) => patch({ name: event.target.value })}
/>
</Field>
<Field label="关联曲目 songId" hint="可留空">
<select value={form.songId} onChange={(event) => patch({ songId: event.target.value })}>
<option value="">不关联</option>
{songs.map((song) => (
<option key={song.id} value={String(song.id)}>
#{song.id} {song.title} — {song.artist}
</option>
))}
</select>
</Field>
<Field label="帧率 fps" error={errors.fps} hint="留空则服务端按缺省 30 处理">
<input
type="number"
step="0.01"
min="0"
value={form.fps}
onChange={(event) => patch({ fps: event.target.value })}
/>
</Field>
<Field label="状态 status">
<select value={form.status} onChange={(event) => patch({ status: event.target.value as PublishStatus })}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
<Field label="上传后自动预处理 autoPreprocess" hint="POST /api/motions/:id/preprocess">
<input
type="checkbox"
checked={form.autoPreprocess}
onChange={(event) => patch({ autoPreprocess: event.target.checked })}
/>
</Field>
</div>
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
</form>
</Modal>
);
}
/* ------------------------------------------------------------------ 页面 */
export function MotionsPage() {
const [filters, setFilters] = useState<MotionFilters>({ q: '', format: '', status: '', page: 1 });
const [qInput, setQInput] = useState('');
const [uploadOpen, setUploadOpen] = useState(false);
const [previewId, setPreviewId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Motion | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
const state = useAsync(
() =>
listMotions({
page: filters.page,
pageSize: PAGE_SIZE,
q: filters.q,
format: filters.format,
status: filters.status,
}),
[filters.q, filters.format, filters.status, filters.page],
);
// 关联曲目下拉 & 表格内的曲名映射
const songsState = useAsync(() => listSongs({ page: 1, pageSize: 200 }), []);
const songs = songsState.data?.items ?? [];
const songTitle = (songId: number | null): string => {
if (songId === null || songId === undefined) return '—';
const song = songs.find((item) => item.id === songId);
return song ? `#${song.id} ${song.title}` : `#${songId}`;
};
const items = state.data?.items ?? [];
const onSaved = (message: string): void => {
setUploadOpen(false);
setNotice({ kind: 'success', text: message });
state.reload();
};
const runPreprocess = async (motion: Motion): Promise<void> => {
setBusyId(motion.id);
try {
await preprocessMotion(motion.id);
setNotice({ kind: 'success', text: `已触发预处理:「${motion.name}」` });
state.reload();
} catch (cause) {
setNotice({ kind: 'error', text: errorMessage(cause) });
} finally {
setBusyId(null);
}
};
const confirmDelete = async (): Promise<void> => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteMotion(deleteTarget.id);
setNotice({ kind: 'success', text: `已删除动作「${deleteTarget.name}」(软删除)` });
setDeleteTarget(null);
state.reload();
} catch (cause) {
setNotice({ kind: 'error', text: errorMessage(cause) });
} finally {
setDeleting(false);
}
};
return (
<div className="page">
<header className="page-head">
<div>
<h1>动作管理</h1>
<p className="muted">T-WEB-004/006 · 动作文件、MediaPipe33 预处理与关键点下载</p>
</div>
<div className="page-head-actions">
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
{state.loading ? '刷新中…' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen(true)}>
+ 上传动作
</button>
</div>
</header>
{notice ? (
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
{notice.text}
</Notice>
) : null}
<form
className="filter-bar"
onSubmit={(event) => {
event.preventDefault();
setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 }));
}}
>
<input
type="search"
placeholder="搜索动作名称(q)"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
/>
<select
value={filters.format}
onChange={(event) =>
setFilters((previous) => ({ ...previous, format: event.target.value, page: 1 }))
}
>
<option value="">全部格式</option>
{MOTION_FORMATS.map((format) => (
<option key={format} value={format}>
{MOTION_FORMAT_LABELS[format]}({format})
</option>
))}
</select>
<select
value={filters.status}
onChange={(event) =>
setFilters((previous) => ({
...previous,
status: event.target.value as PublishStatus | '',
page: 1,
}))
}
>
<option value="">全部状态</option>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button type="submit" className="btn btn-ghost">
查询
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setQInput('');
setFilters({ q: '', format: '', status: '', page: 1 });
}}
>
重置
</button>
</form>
<section className="panel">
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
{state.loading && !state.data ? <Loading text="正在加载动作…" /> : null}
{!state.loading && !state.error && items.length === 0 ? (
<Empty text="还没有动作数据,点击右上角上传 BVH / FBX / glTF 或关键点 JSON。" />
) : null}
{items.length > 0 ? (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>预览</th>
<th>名称</th>
<th>关联曲目</th>
<th>格式</th>
<th>帧率 / 帧数</th>
<th>预处理状态</th>
<th className="col-source">来源五元组</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map((motion) => {
const keypointsReady = motion.preprocessState === 'done';
return (
<tr key={motion.id}>
<td>
{motion.thumbnailUrl ? (
<img
className="thumb-40 thumb-clickable"
src={motion.thumbnailUrl}
alt={`${motion.name} 首帧骨架`}
title="预处理时生成的骨架缩略图(点击预览)"
onClick={() => setPreviewId(previewId === motion.id ? null : motion.id)}
/>
) : (
<span className="muted small" title="预处理后生成">—</span>
)}
</td>
<td>
<div className="cell-title">{motion.name}</div>
<div className="muted small">#{motion.id}</div>
</td>
<td>{songTitle(motion.songId)}</td>
<td>
<FormatBadge format={motion.format} kind="motion" />
</td>
<td className="mono">
{formatNumber(motion.fps)} fps
<div className="muted small">{motion.frameCount ?? '—'} 帧</div>
</td>
<td>
<PreprocessBadge motion={motion} />
{motion.preprocessState === 'failed' && motion.preprocessError ? (
<div className="muted small break" title={motion.preprocessError}>
{motion.preprocessError}
</div>
) : null}
</td>
<td className="col-source">
<SourceCell source={motion} />
</td>
<td>
<StatusBadge status={motion.status} />
</td>
<td className="col-actions">
<button
type="button"
className="link-btn"
disabled={!keypointsReady}
onClick={() => setPreviewId(previewId === motion.id ? null : motion.id)}
title={keypointsReady ? '关键点 3D 回放(播放 / 逐帧 / 时间轴 / 变速 / 循环)' : '预处理完成后才可预览'}
>
{previewId === motion.id ? '收起预览' : '预览'}
</button>
<button
type="button"
className="link-btn"
disabled={busyId === motion.id || motion.preprocessState === 'running'}
onClick={() => runPreprocess(motion)}
title="POST /api/motions/:id/preprocess"
>
{busyId === motion.id ? '触发中…' : '预处理'}
</button>
{keypointsReady ? (
<a
className="link-btn"
href={apiUrl(`/api/motions/${motion.id}/keypoints`)}
download={`motion-${motion.id}-keypoints.ndjson`}
title="GET /api/motions/:id/keypoints(application/x-ndjson)"
>
下载关键点
</a>
) : (
<span className="link-btn link-disabled" title="预处理完成后才可下载(否则服务端返回 409)">
下载关键点
</span>
)}
<button
type="button"
className="link-btn link-danger"
onClick={() => setDeleteTarget(motion)}
>
删除
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
{state.data ? (
<Pagination
page={state.data.page ?? filters.page}
pageSize={state.data.pageSize ?? PAGE_SIZE}
total={state.data.total ?? 0}
disabled={state.loading}
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
/>
) : null}
{previewId !== null ? (
<div className="panel-body motion-preview-panel">
<header className="panel-head">
<h3>动作预览 #{previewId}</h3>
<button type="button" className="link-btn" onClick={() => setPreviewId(null)}>
关闭
</button>
</header>
<Suspense fallback={<Loading text="正在加载 3D 预览…" />}>
<MotionPreview motionId={previewId} />
</Suspense>
</div>
) : null}
</section>
{uploadOpen ? (
<MotionUploadModal
songs={songs}
songsError={songsState.error}
onClose={() => setUploadOpen(false)}
onSaved={onSaved}
/>
) : null}
{deleteTarget ? (
<ConfirmDialog
title="删除动作"
danger
busy={deleting}
confirmText="删除"
message={
<>
<p>
确认删除动作 <strong>{deleteTarget.name}</strong>(#{deleteTarget.id})?
</p>
<p className="muted small">DELETE /api/motions/{deleteTarget.id} · 服务端执行软删除。</p>
</>
}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
) : null}
</div>
);
}