返回 AiToEarn
aideo.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / aideo / aideo.service.ts
1 import { Injectable, Logger } from '@nestjs/common'
2 import { AssetsService } from '@yikart/assets'
3 import { AppException, ResponseCode } from '@yikart/common'
4 import {
5 AideoAiLogResponse,
6 AiLog,
7 AiLogChannel,
8 AiLogRepository,
9 AiLogStatus,
10 AiLogType,
11 AssetType,
12 StyleTransferAiLogResponse,
13 } from '@yikart/mongodb'
14 import { VolcengineVideoUtils } from '../../agent/mcp/volcengine/volcengine.utils'
15 import {
16 AideoTaskStatus,
17 AITranslationApiResponse,
18 AITranslationProjectStatus,
19 ApiResponse,
20 DramaRecapTaskStatus,
21 EraseApiResponse,
22 GetAideoTaskResultResponse,
23 HighlightApiResponse,
24 SkillType,
25 VCreativeApiResponse,
26 VCreativeStatus,
27 VideoInput,
28 VolcengineService,
29 } from '../libs/volcengine'
30 import { parseVolcengineError } from '../libs/volcengine/volcengine.utils'
31 import {
32 UserGetAideoTaskQueryDto,
33 UserGetDramaRecapTaskRequest,
34 UserGetVideoStyleTransferTaskRequest,
35 UserListAideoTasksQueryDto,
36 UserSubmitAideoTaskRequest,
37 UserSubmitDramaRecapTaskRequest,
38 UserSubmitVideoStyleTransferRequest,
39 } from './aideo.dto'
40 import { DramaRecapService } from './drama-recap.service'
41 import { VideoStyleTransferService } from './video-style-transfer.service'
42
43 /**
44 * Aideo 服务
45 * 负责核心 Aideo 任务的提交和查询
46 * 视频风格转换和短剧解说委托给专门的服务
47 */
48 @Injectable()
49 export class AideoService {
50 private readonly logger = new Logger(AideoService.name)
51
52 constructor(
53 private readonly volcengineService: VolcengineService,
54 private readonly aiLogRepo: AiLogRepository,
55 private readonly assetsService: AssetsService,
56 private readonly videoStyleTransferService: VideoStyleTransferService,
57 private readonly dramaRecapService: DramaRecapService,
58 ) { }
59
60 /**
61 * 提交 Aideo 任务
62 */
63 async submitAideoTask(request: UserSubmitAideoTaskRequest) {
64 const { userId, userType, ...params } = request
65
66 const startedAt = new Date()
67
68 const videoInputs: VideoInput[] = params.multiInputs.map((input): VideoInput => {
69 if (typeof input === 'string') {
70 return input
71 }
72 if (input.type === 'url') {
73 return this.assetsService.buildUrl(input.url)
74 }
75
76 return {
77 type: 'stream',
78 stream: input.stream,
79 fileSize: input.fileSize,
80 fileName: input.fileName,
81 fileExtension: input.fileExtension,
82 }
83 })
84
85 this.logger.debug({ inputCount: videoInputs.length }, '视频输入处理完成')
86
87 let taskResult
88 if ('prompt' in params) {
89 taskResult = await this.volcengineService.submitAideoTaskAsyncWithUpload({
90 SpaceName: params.spaceName,
91 MultiInputs: videoInputs,
92 Prompt: params.prompt,
93 })
94 }
95 else {
96 taskResult = await this.volcengineService.submitAideoTaskAsyncWithUpload({
97 SpaceName: params.spaceName,
98 MultiInputs: videoInputs,
99 SkillType: params.skillType,
100 SkillParams: params.skillParams,
101 })
102 }
103
104 this.logger.debug({
105 taskId: taskResult.TaskId,
106 spaceName: params.spaceName,
107 inputVidsCount: videoInputs.length,
108 inputVids: videoInputs.map(v => (typeof v === 'string' ? v : v.type === 'stream' ? 'stream' : v.url)).filter(Boolean).slice(0, 5),
109 }, 'VOLCENGINE Submit summary')
110
111 const aiLog = await this.aiLogRepo.create({
112 userId,
113 userType,
114 taskId: taskResult.TaskId,
115 model: 'aideo',
116 channel: AiLogChannel.Volcengine,
117 startedAt,
118 type: AiLogType.Aideo,
119 request: params,
120 status: AiLogStatus.Generating,
121 })
122
123 return {
124 taskId: aiLog.id,
125 }
126 }
127
128 /**
129 * 处理 Aideo 任务(从火山接口获取结果)
130 * 支持多种任务类型:
131 * - 'aideo': 通用 Aideo 任务(使用 GetAideoTaskResult API)
132 * - 'video-style-transfer': 视频风格转换任务(委托给 VideoStyleTransferService)
133 * - 'drama-recap': 短剧解说任务(委托给 DramaRecapService)
134 */
135 async processAideoTask(task: AiLog) {
136 const taskId = task.taskId
137 if (!taskId) {
138 this.logger.warn({ taskId: task.id }, '任务缺少 taskId,跳过处理')
139 return
140 }
141
142 if (task.model === 'video-style-transfer') {
143 return this.videoStyleTransferService.processVCreativeTask(task)
144 }
145
146 if (task.model === 'drama-recap') {
147 this.logger.log({ taskId: task.id, volcengineTaskId: taskId }, '[DramaRecap] 定时任务查询状态')
148
149 const result = await this.volcengineService.getDramaRecapTask({
150 TaskId: taskId,
151 SpaceName: this.volcengineService.getSpaceName(),
152 })
153
154 this.logger.log({ taskId: task.id, status: result.Status }, '[DramaRecap] 查询结果')
155 if (result.Status === DramaRecapTaskStatus.Completed || result.Status === DramaRecapTaskStatus.Failed) {
156 await this.dramaRecapService.processDramaRecapTask(task, result)
157 }
158 return
159 }
160
161 const taskResult = await this.volcengineService.getAideoTaskResult({
162 TaskId: taskId,
163 })
164
165 const { Status } = taskResult
166
167 if (Status === AideoTaskStatus.Completed) {
168 // 检查是否有有效的API响应
169 if (!taskResult.ApiResponses || taskResult.ApiResponses.length === 0) {
170 this.logger.error(
171 { taskId: task.id, taskResult },
172 '任务完成但没有API响应,可能是火山引擎处理失败',
173 )
174 await this.aiLogRepo.updateById(task.id, {
175 status: AiLogStatus.Failed,
176 response: taskResult,
177 errorMessage: '任务完成但没有有效的输出结果,可能是火山引擎处理失败',
178 })
179 return
180 }
181
182 const apiResponse = taskResult.ApiResponses[0] as ApiResponse
183
184 if (apiResponse.Error) {
185 const errorMessage = apiResponse.Error.Message || '任务执行失败'
186
187 this.logger.error({
188 taskId: task.id,
189 errorCode: apiResponse.Error.Code,
190 errorMessage,
191 vodTaskType: apiResponse.VodTaskType,
192 }, 'API 响应包含错误,任务失败')
193
194 await this.aiLogRepo.updateById(task.id, {
195 status: AiLogStatus.Failed,
196 response: taskResult,
197 errorMessage,
198 })
199 return
200 }
201
202 // 额外验证:对于 AITranslation,确保 ProjectInfo 状态与输出资产已就绪
203 if (apiResponse.VodTaskType === SkillType.AITranslation) {
204 const translationResponse = apiResponse as AITranslationApiResponse
205 const projectInfo = translationResponse.AITranslation?.ProjectInfo
206 const projectStatus = projectInfo?.Status
207
208 const hasAsset = Boolean(
209 projectInfo?.OutputVideo?.Url || projectInfo?.OutputVideo?.Vid || projectInfo?.OutputVideo?.FileName
210 || projectInfo?.FacialTranslationVideo?.Url || projectInfo?.FacialTranslationVideo?.Vid || projectInfo?.FacialTranslationVideo?.FileName
211 || projectInfo?.VoiceTranslationVideo?.Url || projectInfo?.VoiceTranslationVideo?.Vid || projectInfo?.VoiceTranslationVideo?.FileName,
212 )
213
214 const exportingOrProcessingStatuses = new Set([
215 AITranslationProjectStatus.InProcessing,
216 AITranslationProjectStatus.ProcessSuspended,
217 AITranslationProjectStatus.InExporting,
218 ])
219
220 if (!hasAsset || (projectStatus && exportingOrProcessingStatuses.has(projectStatus))) {
221 const elapsed = Date.now() - task.startedAt.getTime()
222
223 this.logger.warn({ taskId: task.id, elapsed, projectStatus, hasAsset }, 'AITranslation 标记为 Completed 但未产出可用资产,延迟处理')
224
225 await this.aiLogRepo.updateById(task.id, { status: AiLogStatus.Generating, response: taskResult })
226 return
227 }
228 }
229
230 // 额外验证:对于 VCreative,检查内部状态是否成功
231 if (apiResponse.VodTaskType === SkillType.VCreative) {
232 const vCreativeResponse = apiResponse as VCreativeApiResponse
233 const vCreative = vCreativeResponse.VCreative
234
235 if (vCreative && vCreative.Status !== VCreativeStatus.Success) {
236 const errorMessage = typeof vCreative.OutputJson === 'string'
237 ? vCreative.OutputJson
238 : (vCreativeResponse.Error?.Message || `VCreative 任务失败: ${vCreative.Status}`)
239
240 this.logger.error({
241 taskId: task.id,
242 vCreativeStatus: vCreative.Status,
243 errorMessage,
244 }, 'VCreative 任务内部状态失败')
245
246 await this.aiLogRepo.updateById(task.id, {
247 status: AiLogStatus.Failed,
248 response: taskResult,
249 errorMessage,
250 })
251 return
252 }
253 }
254
255 await this.updateTaskResult(task, taskResult)
256 }
257 else if (Status === AideoTaskStatus.Failed) {
258 const apiError = taskResult.ApiResponses?.[0]?.Error
259 const errorMessage = apiError?.Message || '任务失败'
260 const errorCode = apiError?.Code
261
262 // 解析错误并提供友好提示
263 let enhancedErrorMessage = errorMessage
264 if (errorCode) {
265 const parsedError = parseVolcengineError(errorCode, errorMessage)
266 enhancedErrorMessage = `${parsedError.userMessage}\n技术详情: ${parsedError.technicalDetails}\n建议: ${parsedError.suggestions.join('; ')}`
267
268 this.logger.error({
269 taskId: task.id,
270 errorCode,
271 userMessage: parsedError.userMessage,
272 suggestions: parsedError.suggestions,
273 }, '任务失败')
274 }
275
276 await this.aiLogRepo.updateById(task.id, {
277 status: AiLogStatus.Failed,
278 response: taskResult,
279 errorMessage: enhancedErrorMessage,
280 })
281 }
282 else if (Status === AideoTaskStatus.Processing) {
283 await this.aiLogRepo.updateById(task.id, {
284 status: AiLogStatus.Generating,
285 response: taskResult,
286 })
287 }
288 }
289
290 /**
291 * 更新任务结果
292 */
293 private async updateTaskResult(
294 task: AiLog,
295 taskResult: GetAideoTaskResultResponse,
296 ) {
297 if (taskResult.ApiResponses && taskResult.ApiResponses.length > 0) {
298 const apiResponse = taskResult.ApiResponses[0] as ApiResponse
299 await this.saveOutputVideos(apiResponse, task)
300 }
301
302 await this.aiLogRepo.updateById(task.id, {
303 status: AiLogStatus.Success,
304 response: taskResult,
305 duration: Date.now() - task.startedAt.getTime(),
306 })
307 }
308
309 /**
310 * 从 VID 获取视频并上传
311 * @param vid 视频 ID
312 * @param task 任务日志
313 * @param filenamePrefix 文件名前缀
314 * @returns URL,如果失败则返回 undefined
315 */
316 private async saveVideoFromVid(
317 vid: string,
318 task: AiLog,
319 filenamePrefix: string,
320 ): Promise<string | undefined> {
321 return VolcengineVideoUtils.saveVideoFromVid(
322 vid,
323 task.userId,
324 `${task.id}-${filenamePrefix}`,
325 task.model || 'aideo',
326 this.volcengineService,
327 this.assetsService,
328 this.logger,
329 AssetType.AideoOutput,
330 )
331 }
332
333 /**
334 * 保存输出视频
335 */
336 private async saveOutputVideos(apiResponse: ApiResponse, task: AiLog) {
337 if (apiResponse.VodTaskType === SkillType.AITranslation) {
338 const translationResponse = apiResponse as AITranslationApiResponse
339 const projectInfo = translationResponse.AITranslation?.ProjectInfo
340 if (projectInfo?.OutputVideo?.Url) {
341 const result = await this.assetsService.uploadFromUrl(task.userId, {
342 url: projectInfo.OutputVideo.Url,
343 type: AssetType.AideoOutput,
344 }, task.model || 'aideo')
345 if (result && projectInfo.OutputVideo) {
346 projectInfo.OutputVideo.Url = this.assetsService.buildUrl(result.asset.path)
347 }
348 }
349 }
350 else if (apiResponse.VodTaskType === SkillType.Erase) {
351 const eraseResponse = apiResponse as EraseApiResponse
352 const erase = eraseResponse.Erase
353 const file = erase?.Output?.Task?.Erase?.File
354
355 if (file?.FileName) {
356 const outputUrl = await VolcengineVideoUtils.saveVideoFromFileName(
357 file.FileName,
358 task.userId,
359 `${task.id}-erase`,
360 task.model || 'aideo',
361 this.assetsService,
362 this.logger,
363 AssetType.AideoOutput,
364 )
365 if (outputUrl && file) {
366 file.url = outputUrl
367 }
368 }
369 }
370 else if (apiResponse.VodTaskType === SkillType.Highlight) {
371 const highlightResponse = apiResponse as HighlightApiResponse
372 const highlight = highlightResponse.Highlight
373 if (highlight?.Edits) {
374 for (const edit of highlight.Edits) {
375 if (edit.Vid) {
376 const outputUrl = await this.saveVideoFromVid(edit.Vid, task, 'highlight')
377 if (outputUrl) {
378 edit.url = outputUrl
379 }
380 }
381 }
382 }
383 }
384 else if (apiResponse.VodTaskType === SkillType.VCreative) {
385 const vCreativeResponse = apiResponse as VCreativeApiResponse
386 const vCreative = vCreativeResponse.VCreative
387 if (!vCreative) {
388 return
389 }
390
391 if (vCreative.Status === VCreativeStatus.Success) {
392 const outputJson = vCreative.OutputJson
393 // 支持新版本和旧版本的数据结构
394 const vid = outputJson.Result?.Vid || outputJson.vid
395 if (vid) {
396 const outputUrl = await this.saveVideoFromVid(vid, task, 'vcreative')
397
398 if (outputUrl) {
399 if (outputJson.Result) {
400 outputJson.Result.url = outputUrl
401 }
402 else {
403 outputJson.url = outputUrl
404 }
405 this.logger.debug({ outputUrl }, '[VCreative] 视频已保存')
406 }
407 else {
408 this.logger.error({ vid }, '[VCreative] 视频保存失败')
409 }
410 }
411 else {
412 this.logger.warn({ outputJson }, '[VCreative] 无法从 outputJson 中提取 VID')
413 }
414 }
415 }
416 }
417
418 /**
419 * 查询 Aideo 任务状态
420 */
421 async getAideoTask(request: UserGetAideoTaskQueryDto) {
422 const { userId, userType, taskId } = request
423
424 const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType)
425
426 if (!aiLog || aiLog.type !== AiLogType.Aideo || aiLog.channel !== AiLogChannel.Volcengine) {
427 throw new AppException(ResponseCode.InvalidAiTaskId)
428 }
429
430 return this.transformToResponseVo(aiLog)
431 }
432
433 /**
434 * 列表查询 Aideo 任务
435 */
436 async listAideoTasks(request: UserListAideoTasksQueryDto) {
437 const { userId, userType, ...pagination } = request
438
439 const [aiLogs, total] = await this.aiLogRepo.listWithPagination({
440 ...pagination,
441 userId,
442 userType,
443 type: AiLogType.Aideo,
444 channel: AiLogChannel.Volcengine,
445 })
446
447 return [
448 await Promise.all(aiLogs.map(log => this.transformToResponseVo(log))),
449 total,
450 ] as const
451 }
452
453 /**
454 * 转换为响应 VO
455 * 支持两种任务类型:
456 * 1. 通用 Aideo 任务(model: 'aideo')
457 * 2. 视频风格转换任务(model: 'video-style-transfer')
458 */
459 private transformToResponseVo(aiLog: AiLog) {
460 // 处理视频风格转换任务
461 if (aiLog.model === 'video-style-transfer') {
462 const response = aiLog.response as StyleTransferAiLogResponse | undefined
463
464 return {
465 taskId: aiLog.id,
466 model: aiLog.model,
467 status: aiLog.status === AiLogStatus.Success
468 ? AideoTaskStatus.Completed
469 : aiLog.status === AiLogStatus.Failed
470 ? AideoTaskStatus.Failed
471 : AideoTaskStatus.Processing,
472 outputVid: response?.outputVid,
473 outputUrl: response?.outputUrl,
474 errorMessage: aiLog.errorMessage,
475 createdAt: aiLog.startedAt,
476 updatedAt: aiLog.updatedAt || aiLog.startedAt,
477 }
478 }
479
480 // 处理通用 Aideo 任务
481 const response = aiLog.response as AideoAiLogResponse | undefined
482 const error = response?.ApiResponses?.[0]?.Error
483 const status = response?.Status && Object.values(AideoTaskStatus).includes(response.Status as AideoTaskStatus)
484 ? response.Status as AideoTaskStatus
485 : aiLog.status === AiLogStatus.Success
486 ? AideoTaskStatus.Completed
487 : aiLog.status === AiLogStatus.Failed
488 ? AideoTaskStatus.Failed
489 : AideoTaskStatus.Processing
490 const skillType = response?.SkillType && Object.values(SkillType).includes(response.SkillType as SkillType)
491 ? response.SkillType as SkillType
492 : undefined
493
494 return {
495 taskId: aiLog.id,
496 model: aiLog.model,
497 status,
498 skillType,
499 skillParams: response?.SkillParams ? JSON.stringify(response.SkillParams) : undefined,
500 apiResponses: response?.ApiResponses,
501 error: error ? { code: error.Code, message: error.Message } : undefined,
502 errorMessage: aiLog.errorMessage,
503 createdAt: aiLog.startedAt,
504 updatedAt: aiLog.updatedAt || aiLog.startedAt,
505 }
506 }
507
508 // ========== 委托方法(保持向后兼容) ==========
509
510 /**
511 * 提交视频风格转换任务
512 * @deprecated 直接使用 VideoStyleTransferService.submitVideoStyleTransferTask
513 */
514 async submitVideoStyleTransferTask(
515 request: UserSubmitVideoStyleTransferRequest,
516 ): Promise<{ taskId: string }> {
517 return this.videoStyleTransferService.submitVideoStyleTransferTask(request)
518 }
519
520 /**
521 * 获取视频风格转换任务结果
522 * @deprecated 直接使用 VideoStyleTransferService.getVideoStyleTransferTask
523 */
524 async getVideoStyleTransferTask(
525 request: UserGetVideoStyleTransferTaskRequest,
526 ) {
527 return this.videoStyleTransferService.getVideoStyleTransferTask(request)
528 }
529
530 /**
531 * 提交短剧解说任务
532 * @deprecated 直接使用 DramaRecapService.submitDramaRecapTask
533 */
534 async submitDramaRecapTask(
535 request: UserSubmitDramaRecapTaskRequest,
536 ): Promise<{ taskId: string, dramaScriptTaskId: string }> {
537 return this.dramaRecapService.submitDramaRecapTask(request)
538 }
539
540 /**
541 * 获取短剧解说任务结果
542 * @deprecated 直接使用 DramaRecapService.getDramaRecapTask
543 */
544 async getDramaRecapTask(
545 request: UserGetDramaRecapTaskRequest,
546 ): Promise<{
547 taskId: string
548 status: DramaRecapTaskStatus
549 outputVid?: string
550 outputUrl?: string
551 errorMessage?: string
552 }> {
553 return this.dramaRecapService.getDramaRecapTask(request)
554 }
555 }
556
556 lines TYPESCRIPT