feat(web): T-WEB-006 BVH/FBX → MediaPipe33 关键点预处理
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetKind, MotionFormat, PreprocessState } from '../common/enums';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { Motion } from '../entities/motion.entity';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { parseBvh } from './bvh-parser';
|
||||
import { parseKeypointsJson, serializeFramesNdjson } from './keypoint-codec';
|
||||
import { KEYPOINT_FORMAT, KeypointFrame } from './mediapipe33';
|
||||
import { bvhToKeypointFrames } from './retarget';
|
||||
import { convertToBvhWithAssimp } from './assimp';
|
||||
|
||||
export interface PreprocessOutcome {
|
||||
frames: number;
|
||||
fps: number;
|
||||
durationMs: number;
|
||||
format: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* T-WEB-006:BVH / FBX → MediaPipe 33 点关键点序列预处理。
|
||||
*
|
||||
* - BVH:内置纯 TS 解析器 + FK + 重定向,无外部依赖
|
||||
* - glTF / GLB / FBX:调用可选的外部 Assimp 转为 BVH 后再走同一通路
|
||||
* - keypoints-json:直通校验与规范化
|
||||
*/
|
||||
@Injectable()
|
||||
export class PreprocessService {
|
||||
private readonly logger = new Logger(PreprocessService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
/** 执行预处理并落库;异常会被记录到 preprocessError 后重新抛出 */
|
||||
async run(motion: Motion): Promise<Motion> {
|
||||
const started = Date.now();
|
||||
motion.preprocessState = PreprocessState.Running;
|
||||
motion.preprocessError = null;
|
||||
await this.motions.save(motion);
|
||||
|
||||
try {
|
||||
const result = await this.convert(motion);
|
||||
const ndjson = serializeFramesNdjson(result.frames);
|
||||
|
||||
const stored = await this.storage.persistBuffer(
|
||||
AssetKind.Keypoints,
|
||||
ndjson,
|
||||
`motion-${motion.id}.ndjson`,
|
||||
'application/x-ndjson',
|
||||
);
|
||||
|
||||
motion.keypointFormat = KEYPOINT_FORMAT;
|
||||
motion.keypointAssetId = stored.asset.id;
|
||||
motion.keypointFrames = result.frames.length;
|
||||
motion.fps = result.fps;
|
||||
motion.frameCount = result.frames.length;
|
||||
motion.durationMs = result.durationMs;
|
||||
motion.preprocessState = PreprocessState.Done;
|
||||
motion.preprocessError = null;
|
||||
|
||||
const saved = await this.motions.save(motion);
|
||||
this.logger.log(
|
||||
`动作 #${motion.id} 预处理完成:${result.frames.length} 帧,${Date.now() - started}ms`,
|
||||
);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
motion.preprocessState = PreprocessState.Failed;
|
||||
motion.preprocessError = message.slice(0, 2000);
|
||||
await this.motions.save(motion);
|
||||
this.logger.error(`动作 #${motion.id} 预处理失败:${message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取已完成预处理的关键点 NDJSON */
|
||||
async readKeypointsNdjson(motion: Motion): Promise<string> {
|
||||
if (motion.preprocessState !== PreprocessState.Done || !motion.keypointAssetId) {
|
||||
throw new BadRequestException('该动作尚未完成关键点预处理,请先调用 /preprocess');
|
||||
}
|
||||
const asset = await this.storage.findById(motion.keypointAssetId);
|
||||
if (!asset) throw new NotFoundException('关键点文件已丢失');
|
||||
return this.storage.readAssetText(asset);
|
||||
}
|
||||
|
||||
private async convert(
|
||||
motion: Motion,
|
||||
): Promise<{ frames: KeypointFrame[]; fps: number; durationMs: number }> {
|
||||
const asset = await this.storage.findById(motion.fileAssetId);
|
||||
if (!asset) throw new BadRequestException('动作源文件缺失,无法预处理');
|
||||
const sourcePath = this.storage.absolutePath(asset);
|
||||
|
||||
let workDir: string | null = null;
|
||||
try {
|
||||
if (motion.format === MotionFormat.Bvh) {
|
||||
const text = await readFile(sourcePath, 'utf8');
|
||||
return this.fromBvh(text, motion.fps ?? undefined);
|
||||
}
|
||||
|
||||
if (motion.format === MotionFormat.KeypointsJson) {
|
||||
const text = await readFile(sourcePath, 'utf8');
|
||||
const { frames, fps } = parseKeypointsJson(text);
|
||||
if (frames.length === 0) throw new BadRequestException('关键点 JSON 中没有任何帧');
|
||||
const effectiveFps = fps ?? motion.fps ?? 30;
|
||||
return {
|
||||
frames: frames.map((frame, index) => ({
|
||||
...frame,
|
||||
frame: index,
|
||||
timeMs: frame.timeMs || Math.round((index / effectiveFps) * 1000 * 1000) / 1000,
|
||||
})),
|
||||
fps: effectiveFps,
|
||||
durationMs: Math.round((frames.length / effectiveFps) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// FBX / glTF / GLB:交给可选的外部 Assimp
|
||||
workDir = await mkdtemp(join(tmpdir(), 'opendance-assimp-'));
|
||||
const bvhPath = await convertToBvhWithAssimp(sourcePath, workDir, this.config.assimpBin);
|
||||
const text = await readFile(bvhPath, 'utf8');
|
||||
return this.fromBvh(text, motion.fps ?? undefined);
|
||||
} finally {
|
||||
if (workDir) await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private fromBvh(text: string, fpsOverride?: number): { frames: KeypointFrame[]; fps: number; durationMs: number } {
|
||||
const motion = parseBvh(text);
|
||||
if (motion.frameCount === 0) throw new BadRequestException('BVH 中没有帧');
|
||||
const frames = bvhToKeypointFrames(motion, { fps: fpsOverride });
|
||||
const fps = fpsOverride && fpsOverride > 0 ? fpsOverride : 1 / motion.frameTime;
|
||||
return {
|
||||
frames,
|
||||
fps: Math.round(fps * 1000) / 1000,
|
||||
durationMs: Math.round((frames.length / fps) * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user