| 1 | import { Injectable, Logger } from '@nestjs/common' |
| 2 | import { AssetsService } from '@yikart/assets' |
| 3 | import { AppException, ResponseCode, UserType } from '@yikart/common' |
| 4 | import { |
| 5 | AideoAiLogResponse, |
| 6 | AiLog, |
| 7 | AiLogChannel, |
| 8 | AiLogRepository, |
| 9 | AiLogStatus, |
| 10 | AiLogType, |
| 11 | AssetType, |
| 12 | } from '@yikart/mongodb' |
| 13 | import { VolcengineVideoUtils } from '../../agent/mcp/volcengine/volcengine.utils' |
| 14 | import { |
| 15 | CreateDramaRecapTaskRequest, |
| 16 | CreateDramaRecapTaskResponse, |
| 17 | DramaRecapTaskStatus, |
| 18 | QueryDramaRecapTaskResponse, |
| 19 | VolcengineService, |
| 20 | } from '../libs/volcengine' |
| 21 | import { UserGetDramaRecapTaskRequest, UserSubmitDramaRecapTaskRequest } from './aideo.dto' |
| 22 | |
| 23 | /** |
| 24 | * 短剧解说服务 |
| 25 | * 负责短剧解说任务的提交、查询和计费 |
| 26 | */ |
| 27 | @Injectable() |
| 28 | export class DramaRecapService { |
| 29 | private readonly logger = new Logger(DramaRecapService.name) |
| 30 | |
| 31 | constructor( |
| 32 | private readonly volcengineService: VolcengineService, |
| 33 | private readonly aiLogRepo: AiLogRepository, |
| 34 | private readonly assetsService: AssetsService, |
| 35 | ) { } |
| 36 | |
| 37 | /** |
| 38 | * 提交短剧解说任务 |
| 39 | */ |
| 40 | async submitDramaRecapTask( |
| 41 | request: UserSubmitDramaRecapTaskRequest, |
| 42 | ): Promise<{ taskId: string, dramaScriptTaskId: string }> { |
| 43 | const { |
| 44 | userId, |
| 45 | userType, |
| 46 | vids, |
| 47 | dramaScriptTaskId, |
| 48 | recapText, |
| 49 | speakerConfig, |
| 50 | isEraseSubtitle = true, |
| 51 | fontConfig, |
| 52 | recapStyle, |
| 53 | recapTextSpeed = 1.2, |
| 54 | recapTextLength, |
| 55 | pauseTime = 120, |
| 56 | allowRepeatMatch = false, |
| 57 | } = request |
| 58 | |
| 59 | this.logger.log({ userId, vids, dramaScriptTaskId }, '[DramaRecap] 提交任务') |
| 60 | |
| 61 | const startedAt = new Date() |
| 62 | |
| 63 | // 1. 处理输入视频(如果是 URL,先上传获取 VID) |
| 64 | const processedVids = await Promise.all(vids.map(async (videoInput) => { |
| 65 | let vid: string |
| 66 | if (videoInput.startsWith('vid://')) { |
| 67 | vid = videoInput.replace('vid://', '') |
| 68 | } |
| 69 | else if (videoInput.startsWith('http://') || videoInput.startsWith('https://')) { |
| 70 | vid = await this.volcengineService.downloadUrlAndUploadAsStream(videoInput) |
| 71 | } |
| 72 | else { |
| 73 | // 假设直接传入的就是 VID |
| 74 | vid = videoInput |
| 75 | } |
| 76 | return vid |
| 77 | })) |
| 78 | |
| 79 | // 构建请求参数 |
| 80 | const apiRequest: CreateDramaRecapTaskRequest = { |
| 81 | SpaceName: this.volcengineService.getSpaceName(), |
| 82 | Vids: processedVids, |
| 83 | DramaScriptTaskId: dramaScriptTaskId, |
| 84 | // 短剧解说配置 |
| 85 | DramaRecapConfig: { |
| 86 | // 如果提供了自定义解说词,使用它;否则启用自动生成 |
| 87 | ...(recapText ? { RecapText: recapText } : { AutoGenerateRecapText: true }), |
| 88 | ...(recapStyle && { RecapStyle: recapStyle }), |
| 89 | ...(recapTextSpeed !== undefined && { RecapTextSpeed: recapTextSpeed }), |
| 90 | ...(recapTextLength !== undefined && { RecapTextLength: recapTextLength }), |
| 91 | ...(pauseTime !== undefined && { PauseTime: pauseTime }), |
| 92 | ...(allowRepeatMatch !== undefined && { AllowRepeatMatch: allowRepeatMatch }), |
| 93 | }, |
| 94 | ...(speakerConfig && { |
| 95 | SpeakerConfig: { |
| 96 | AppId: speakerConfig.appId, |
| 97 | Cluster: speakerConfig.cluster, |
| 98 | VoiceType: speakerConfig.voiceType, |
| 99 | }, |
| 100 | }), |
| 101 | IsEraseSubtitle: isEraseSubtitle, |
| 102 | ...(fontConfig && { |
| 103 | FontConfig: { |
| 104 | Color: fontConfig.color, |
| 105 | Size: fontConfig.size, |
| 106 | Name: fontConfig.name, |
| 107 | }, |
| 108 | }), |
| 109 | } |
| 110 | |
| 111 | // 提交任务 |
| 112 | const response: CreateDramaRecapTaskResponse = await this.volcengineService.createDramaRecapTask(apiRequest) |
| 113 | |
| 114 | const { TaskId: volcengineTaskId, DramaScriptTaskId: responseDramaScriptTaskId } = response |
| 115 | |
| 116 | this.logger.log({ volcengineTaskId, dramaScriptTaskId: responseDramaScriptTaskId }, '[DramaRecap] 任务提交成功') |
| 117 | |
| 118 | const aiLog = await this.aiLogRepo.create({ |
| 119 | userId, |
| 120 | userType: userType as UserType, |
| 121 | taskId: volcengineTaskId, |
| 122 | model: 'drama-recap', |
| 123 | channel: AiLogChannel.Volcengine, |
| 124 | startedAt, |
| 125 | type: AiLogType.Aideo, |
| 126 | request: { |
| 127 | vids, |
| 128 | dramaScriptTaskId, |
| 129 | recapText, |
| 130 | speakerConfig, |
| 131 | isEraseSubtitle, |
| 132 | fontConfig, |
| 133 | recapStyle, |
| 134 | recapTextSpeed, |
| 135 | recapTextLength, |
| 136 | pauseTime, |
| 137 | allowRepeatMatch, |
| 138 | }, |
| 139 | response: { taskId: volcengineTaskId, dramaScriptTaskId: responseDramaScriptTaskId }, |
| 140 | status: AiLogStatus.Generating, |
| 141 | }) |
| 142 | |
| 143 | return { taskId: aiLog.id, dramaScriptTaskId: responseDramaScriptTaskId } |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * 获取短剧解说任务结果(用户主动查询接口) |
| 148 | * 注意:定时任务会自动处理任务状态,此方法主要用于用户主动查询 |
| 149 | */ |
| 150 | async getDramaRecapTask( |
| 151 | request: UserGetDramaRecapTaskRequest, |
| 152 | ): Promise<{ |
| 153 | taskId: string |
| 154 | status: DramaRecapTaskStatus |
| 155 | outputVid?: string |
| 156 | outputUrl?: string |
| 157 | errorMessage?: string |
| 158 | }> { |
| 159 | const { taskId } = request |
| 160 | |
| 161 | this.logger.log({ taskId }, '[DramaRecap] 查询任务状态') |
| 162 | |
| 163 | // 从数据库查询任务记录(taskId 是数据库记录的 id,不是火山引擎的 TaskId) |
| 164 | const log = await this.aiLogRepo.getById(taskId) |
| 165 | |
| 166 | if (!log || log.model !== 'drama-recap') { |
| 167 | throw new AppException(ResponseCode.InvalidAiTaskId) |
| 168 | } |
| 169 | |
| 170 | // 获取火山引擎的任务 ID |
| 171 | const volcengineTaskId = log.taskId |
| 172 | if (!volcengineTaskId) { |
| 173 | throw new AppException(ResponseCode.InvalidAiTaskId) |
| 174 | } |
| 175 | |
| 176 | // 如果任务已完成或失败,直接返回数据库中的结果 |
| 177 | if (log.status === AiLogStatus.Success) { |
| 178 | const response = log.response as AideoAiLogResponse | undefined |
| 179 | return { |
| 180 | taskId, |
| 181 | status: DramaRecapTaskStatus.Completed, |
| 182 | outputVid: response?.outputVid, |
| 183 | outputUrl: response?.outputUrl, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | if (log.status === AiLogStatus.Failed) { |
| 188 | const response = log.response as AideoAiLogResponse | undefined |
| 189 | return { |
| 190 | taskId, |
| 191 | status: DramaRecapTaskStatus.Failed, |
| 192 | errorMessage: log.errorMessage || response?.errorMessage || '任务执行失败', |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // 查询火山引擎任务状态 |
| 197 | const result: QueryDramaRecapTaskResponse = await this.volcengineService.getDramaRecapTask({ |
| 198 | TaskId: volcengineTaskId, |
| 199 | SpaceName: this.volcengineService.getSpaceName(), |
| 200 | }) |
| 201 | |
| 202 | // 如果任务已完成,触发处理逻辑 |
| 203 | if (result.Status === DramaRecapTaskStatus.Completed || result.Status === DramaRecapTaskStatus.Failed) { |
| 204 | // 调用 processDramaRecapTask 处理任务(更新数据库、下载视频、扣费等) |
| 205 | await this.processDramaRecapTask(log, result) |
| 206 | |
| 207 | // 重新查询数据库获取最新状态 |
| 208 | const updatedLog = await this.aiLogRepo.getById(log.id) |
| 209 | if (updatedLog) { |
| 210 | const response = updatedLog.response as AideoAiLogResponse | undefined |
| 211 | return { |
| 212 | taskId, |
| 213 | status: updatedLog.status === AiLogStatus.Success ? DramaRecapTaskStatus.Completed : DramaRecapTaskStatus.Failed, |
| 214 | outputVid: response?.outputVid, |
| 215 | outputUrl: response?.outputUrl, |
| 216 | errorMessage: updatedLog.errorMessage, |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | return { |
| 222 | taskId, |
| 223 | status: result.Status, |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * 处理短剧解说任务结果(下载视频、上传 S3、计费) |
| 229 | */ |
| 230 | async processDramaRecapTask(task: AiLog, result: QueryDramaRecapTaskResponse): Promise<void> { |
| 231 | if (result.Status === DramaRecapTaskStatus.Completed && result.Vid) { |
| 232 | this.logger.log({ Vid: result.Vid }, '[DramaRecap] 开始下载视频并上传到 S3') |
| 233 | |
| 234 | // 下载视频并上传到 S3 |
| 235 | const s3Url = await this.saveVideoFromVid(result.Vid, task, 'drama-recap') |
| 236 | |
| 237 | // 更新响应数据 |
| 238 | const response = task.response as AideoAiLogResponse | undefined |
| 239 | const updatedResponse = { |
| 240 | ...(response || {}), |
| 241 | outputVid: result.Vid, |
| 242 | outputUrl: s3Url, |
| 243 | } |
| 244 | |
| 245 | await this.aiLogRepo.updateById(task.id, { |
| 246 | status: AiLogStatus.Success, |
| 247 | finishedAt: new Date(), |
| 248 | response: updatedResponse, |
| 249 | }) |
| 250 | |
| 251 | this.logger.log({ taskId: task.taskId, outputVid: result.Vid }, '[DramaRecap] 任务处理完成') |
| 252 | } |
| 253 | else if (result.Status === DramaRecapTaskStatus.Failed) { |
| 254 | // 更新任务状态为失败 |
| 255 | await this.aiLogRepo.updateById(task.id, { |
| 256 | status: AiLogStatus.Failed, |
| 257 | finishedAt: new Date(), |
| 258 | response: { ...result, errorMessage: result.ErrorMessage }, |
| 259 | errorMessage: result.ErrorMessage, |
| 260 | }) |
| 261 | |
| 262 | this.logger.error({ taskId: task.taskId, errorMessage: result.ErrorMessage }, '[DramaRecap] 任务失败') |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * 从 VID 获取视频并上传 |
| 268 | */ |
| 269 | private async saveVideoFromVid( |
| 270 | vid: string, |
| 271 | task: AiLog, |
| 272 | filenamePrefix: string, |
| 273 | ): Promise<string | undefined> { |
| 274 | return VolcengineVideoUtils.saveVideoFromVid( |
| 275 | vid, |
| 276 | task.userId, |
| 277 | `${task.id}-${filenamePrefix}`, |
| 278 | task.model || 'drama-recap', |
| 279 | this.volcengineService, |
| 280 | this.assetsService, |
| 281 | this.logger, |
| 282 | AssetType.AideoOutput, |
| 283 | ) |
| 284 | } |
| 285 | } |
| 286 |