Files
opendance-web/web-server/src/preprocess/preprocess.service.ts
T

145 lines
5.6 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 { 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),
};
}
}