import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import { existsSync } from 'fs'; import { mkdir, readFile, readdir, writeFile } from 'fs/promises'; import { join, resolve } from 'path'; import { AccountProfileService } from '../auth/account_profile.service'; import { CreateSkinGenerationJobDto } from './dto/create_skin_generation_job.dto'; import { SkinGenerationJob, SkinGenerationJobResponse } from './skin_generation.types'; @Injectable() export class SkinGenerationService { private readonly logger = new Logger(SkinGenerationService.name); private readonly jobs = new Map(); constructor( private readonly configService: ConfigService, private readonly accountProfileService: AccountProfileService, ) {} async createJob(userId: bigint, dto: CreateSkinGenerationJobDto): Promise { this.accountProfileService.assertCustomSkinCreationAvailable(); const apiKey = this.configService.get('NOVAMAILIO_API_KEY') || process.env.NOVAMAILIO_API_KEY; if (!apiKey || apiKey.trim().length === 0) { throw new BadRequestException('服务端尚未配置 NOVAMAILIO_API_KEY,无法生成角色皮肤'); } await this.ensureWorkerRuntime(); if (!(await this.accountProfileService.canUseRegistrationSkinGeneration(userId))) { throw new BadRequestException('该账号没有可用的注册角色生成机会'); } if (await this.accountProfileService.hasRegistrationGeneratedSkin(userId)) { throw new BadRequestException('该账号已经使用过注册角色生成机会'); } const activeJob = Array.from(this.jobs.values()).find( (job) => job.userId === userId.toString() && (job.status === 'queued' || job.status === 'running'), ); if (activeJob) { return this.toResponse(activeJob); } const jobId = randomUUID(); const ownerId = userId.toString(); const now = Date.now(); const safeName = this.sanitizeName(dto.name || '我的角色'); const outDir = join(this.getOutputRoot(), ownerId, jobId); const sourceImagePath = join(outDir, 'source_character.png'); await mkdir(outDir, { recursive: true }); await this.saveSourceImage(dto.source_image_base64, sourceImagePath); const job: SkinGenerationJob = { jobId, userId: ownerId, status: 'queued', stage: 'queued', message: '生成任务已创建,等待开始', name: safeName, sourceImagePath, createdAt: now, updatedAt: now, outDir, statusJsonPath: join(outDir, 'status.json'), resultJsonPath: join(outDir, 'result.json'), }; this.jobs.set(jobId, job); void this.runJob(job); return this.toResponse(job); } async getJob(userId: bigint, jobId: string): Promise { const job = this.jobs.get(jobId) || await this.restoreJobFromDisk(jobId); if (!job) { throw new NotFoundException('皮肤生成任务不存在或已过期'); } if (job.userId !== userId.toString()) { throw new ForbiddenException('无权查看该皮肤生成任务'); } await this.refreshJobFromFiles(job); return this.toResponse(job); } private async runJob(job: SkinGenerationJob): Promise { job.status = 'running'; job.stage = 'start'; job.message = '正在启动服务器角色生成流程'; job.updatedAt = Date.now(); try { const scriptPath = this.getScriptPath(); const pythonPath = this.getPythonPath(); if (!existsSync(scriptPath)) { throw new Error(`皮肤生成脚本不存在: ${scriptPath}`); } const child = spawn( pythonPath, [ scriptPath, '--source-image', job.sourceImagePath, '--out-dir', job.outDir, '--name', job.name, '--result-json', job.resultJsonPath, '--status-json', job.statusJsonPath, ], { cwd: this.getBackendRoot(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }, ); child.stdout.on('data', (chunk: Buffer) => this.consumeWorkerOutput(job, chunk)); child.stderr.on('data', (chunk: Buffer) => this.consumeWorkerOutput(job, chunk)); const workerExit = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>( (resolveExit, rejectExit) => { child.on('error', rejectExit); child.on('close', (exitCode, signal) => resolveExit({ exitCode, signal })); }, ); await this.refreshJobFromFiles(job); if (workerExit.signal) { throw new Error(`生成进程被中断:${workerExit.signal}`); } if (workerExit.exitCode !== 0 || job.stage === 'failed' || job.error) { throw new Error(job.error || job.message || `生成进程退出码: ${workerExit.exitCode}`); } job.status = 'completed'; job.stage = 'done'; job.message = '新角色皮肤已生成'; job.updatedAt = Date.now(); if (job.spritesheetPath) { const skinAsset = await this.accountProfileService.saveGeneratedSkinForUser( BigInt(job.userId), job.spritesheetPath, job.name, 'generated_registration', ); await this.accountProfileService.consumeRegistrationSkinGeneration(BigInt(job.userId)); job.skinId = skinAsset.skinId; job.textureUrl = skinAsset.texture_url; } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`皮肤生成失败: ${job.jobId} ${message}`); job.status = 'failed'; job.stage = 'failed'; job.message = `生成失败:${message}`; job.error = message; job.updatedAt = Date.now(); } } private consumeWorkerOutput(job: SkinGenerationJob, chunk: Buffer): void { const line = chunk.toString('utf8').trim(); if (!line) { return; } job.message = line.split('\n').pop() || job.message; job.updatedAt = Date.now(); } private async refreshJobFromFiles(job: SkinGenerationJob): Promise { await this.refreshStatusJson(job); await this.refreshResultJson(job); } private async restoreJobFromDisk(jobId: string): Promise { const outputRoot = this.getOutputRoot(); try { const ownerDirs = await readdir(outputRoot, { withFileTypes: true }); for (const ownerDir of ownerDirs) { if (!ownerDir.isDirectory()) { continue; } const outDir = join(outputRoot, ownerDir.name, jobId); if (!existsSync(outDir)) { continue; } const now = Date.now(); const job: SkinGenerationJob = { jobId, userId: ownerDir.name, status: 'running', stage: 'restored', message: '正在恢复生成任务状态', name: 'custom_whale_human', sourceImagePath: join(outDir, 'source_character.png'), createdAt: now, updatedAt: now, outDir, statusJsonPath: join(outDir, 'status.json'), resultJsonPath: join(outDir, 'result.json'), }; await this.refreshJobFromFiles(job); this.jobs.set(jobId, job); return job; } } catch { return null; } return null; } private async refreshStatusJson(job: SkinGenerationJob): Promise { const payload = await this.readJson(job.statusJsonPath); if (!payload) { return; } job.stage = String(payload.stage || job.stage); job.message = String(payload.message || job.message); job.updatedAt = Number(payload.updated_at ? Math.floor(payload.updated_at * 1000) : Date.now()); if (payload.ok === false && job.stage === 'failed') { job.status = 'failed'; job.error = job.message.replace(/^生成失败:/, ''); } } private async refreshResultJson(job: SkinGenerationJob): Promise { const payload = await this.readJson(job.resultJsonPath); if (!payload) { return; } job.logPath = String(payload.log_path || job.logPath || ''); if (payload.ok === true) { job.status = 'completed'; job.stage = 'done'; job.message = '新角色皮肤已生成'; job.spritesheetPath = String(payload.spritesheet_path || ''); job.reviewPath = String(payload.review_path || ''); job.feetZoomPath = String(payload.feet_zoom_path || ''); job.updatedAt = Date.now(); return; } if (payload.ok === false && payload.error) { job.status = 'failed'; job.stage = 'failed'; job.error = String(payload.error); job.message = `生成失败:${job.error}`; job.updatedAt = Date.now(); } } private async readJson(path: string): Promise | null> { try { if (!existsSync(path)) { return null; } const text = await readFile(path, 'utf8'); if (!text.trim()) { return null; } return JSON.parse(text); } catch { return null; } } private async toResponse(job: SkinGenerationJob): Promise { await this.ensureCompletedJobSkinAsset(job); const response: SkinGenerationJobResponse = { job_id: job.jobId, status: job.status, stage: job.stage, message: job.message, name: job.name, created_at: job.createdAt, updated_at: job.updatedAt, hframes: 8, vframes: 4, }; if (job.status === 'failed' && job.error) { response.error = job.error; } if (job.status === 'completed' && job.spritesheetPath && existsSync(job.spritesheetPath)) { const image = await readFile(job.spritesheetPath); response.spritesheet_base64 = image.toString('base64'); response.mime_type = 'image/png'; response.skin_id = job.skinId; response.texture_url = job.textureUrl; } return response; } private async ensureCompletedJobSkinAsset(job: SkinGenerationJob): Promise { if (job.status !== 'completed' || !job.spritesheetPath || job.skinId) { return; } if (!existsSync(job.spritesheetPath)) { return; } const skinAsset = await this.accountProfileService.saveGeneratedSkinForUser( BigInt(job.userId), job.spritesheetPath, job.name, 'generated_registration', ); await this.accountProfileService.consumeRegistrationSkinGeneration(BigInt(job.userId)); job.skinId = skinAsset.skinId; job.textureUrl = skinAsset.texture_url; } private getOutputRoot(): string { return resolve(this.getBackendRoot(), this.configService.get('SKIN_GENERATION_OUTPUT_DIR') || 'generated/skins'); } private getScriptPath(): string { return resolve( this.getBackendRoot(), this.configService.get('SKIN_GENERATION_SCRIPT_PATH') || 'scripts/skin_generation/generate_skin_from_prompt.py', ); } private getPythonPath(): string { return this.configService.get('SKIN_GENERATION_PYTHON') || process.env.PYTHON || 'python3'; } private getBackendRoot(): string { return resolve(__dirname, '../../..'); } private async ensureWorkerRuntime(): Promise { const scriptPath = this.getScriptPath(); if (!existsSync(scriptPath)) { throw new BadRequestException(`服务端角色生成脚本不存在: ${scriptPath}`); } const pythonPath = this.getPythonPath(); const result = await new Promise<{ exitCode: number | null; stderr: string }>((resolveResult) => { const child = spawn( pythonPath, [ '-c', 'import einops, kornia, numpy, scipy, timm, torch, torchvision, transformers; from PIL import Image', ], { cwd: this.getBackendRoot(), env: process.env, stdio: ['ignore', 'ignore', 'pipe'], }, ); let stderr = ''; child.stderr.on('data', (chunk: Buffer) => { if (stderr.length < 2000) stderr += chunk.toString('utf8'); }); child.on('error', (error) => resolveResult({ exitCode: null, stderr: error.message })); child.on('close', (exitCode) => resolveResult({ exitCode, stderr })); }); if (result.exitCode !== 0) { this.logger.error(`角色生成运行时不可用: python=${pythonPath} ${result.stderr.trim()}`); throw new BadRequestException('服务端角色生成运行时未就绪,请联系管理员'); } } private async saveSourceImage(base64: string, destinationPath: string): Promise { const normalized = base64.trim().replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, ''); let buffer: Buffer; try { buffer = Buffer.from(normalized, 'base64'); } catch { throw new BadRequestException('角色参考图解析失败'); } if (buffer.length < 512) { throw new BadRequestException('角色参考图内容为空或过小'); } if (buffer.length > 8 * 1024 * 1024) { throw new BadRequestException('角色参考图不能超过8MB'); } const isPng = buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; const isWebp = buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP'; if (!isPng && !isJpeg && !isWebp) { throw new BadRequestException('角色参考图必须是 PNG、JPG 或 WebP'); } await writeFile(destinationPath, buffer); } private sanitizeName(value: string): string { const normalized = value .trim() .toLowerCase() .replace(/[^a-zA-Z0-9_\u4e00-\u9fa5]+/g, '_') .replace(/_+/g, '_') .replace(/^_+|_+$/g, ''); return (normalized || 'custom_whale_human').slice(0, 40); } }