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, private readonly storage: StorageService, private readonly config: AppConfig, ) {} /** 执行预处理并落库;异常会被记录到 preprocessError 后重新抛出 */ async run(motion: Motion): Promise { 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 { 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), }; } }