| 1 | import { Injectable, Logger } from '@nestjs/common' |
| 2 | import { AssetsService } from '@yikart/assets' |
| 3 | import { AppException, getErrorMessage, ResponseCode, UserType } from '@yikart/common' |
| 4 | import { |
| 5 | AiLog, |
| 6 | AiLogChannel, |
| 7 | AiLogRepository, |
| 8 | AiLogStatus, |
| 9 | AiLogType, |
| 10 | AssetType, |
| 11 | StyleTransferAiLogResponse, |
| 12 | } from '@yikart/mongodb' |
| 13 | import { isAxiosError } from 'axios' |
| 14 | import { VolcengineVideoUtils } from '../../agent/mcp/volcengine/volcengine.utils' |
| 15 | import { |
| 16 | AsyncVCreativeTaskParamObj, |
| 17 | GetVCreativeTaskResultResponse, |
| 18 | VCreativeTaskStatus, |
| 19 | VolcengineService, |
| 20 | } from '../libs/volcengine' |
| 21 | import { UserGetVideoStyleTransferTaskRequest, UserSubmitVideoStyleTransferRequest } from './aideo.dto' |
| 22 | |
| 23 | /** |
| 24 | * 视频风格转换服务 |
| 25 | * 负责 VCreative 视频风格转换任务的提交和查询 |
| 26 | */ |
| 27 | @Injectable() |
| 28 | export class VideoStyleTransferService { |
| 29 | private readonly logger = new Logger(VideoStyleTransferService.name) |
| 30 | |
| 31 | constructor( |
| 32 | private readonly volcengineService: VolcengineService, |
| 33 | private readonly aiLogRepo: AiLogRepository, |
| 34 | private readonly assetsService: AssetsService, |
| 35 | ) { } |
| 36 | |
| 37 | /** |
| 38 | * 提交视频风格转换任务(使用 AsyncVCreativeTask API) |
| 39 | */ |
| 40 | async submitVideoStyleTransferTask( |
| 41 | request: UserSubmitVideoStyleTransferRequest, |
| 42 | ): Promise<{ taskId: string }> { |
| 43 | const { userId, userType, videoInput, style, resolution } = request |
| 44 | |
| 45 | this.logger.debug({ userId, videoInput, style, originalStyle: style, resolution }, '[VideoStyleTransfer] 提交任务') |
| 46 | const startedAt = new Date() |
| 47 | |
| 48 | // 1. 处理输入视频 |
| 49 | let vid: string |
| 50 | if (videoInput.startsWith('vid://')) { |
| 51 | vid = videoInput.replace('vid://', '') |
| 52 | this.logger.debug({ vid }, '[VideoStyleTransfer] 使用已有 VID') |
| 53 | } |
| 54 | else if (videoInput.startsWith('http://') || videoInput.startsWith('https://')) { |
| 55 | // 优先使用流上传(更快更可靠) |
| 56 | this.logger.debug({ url: videoInput }, '[VideoStyleTransfer] 开始下载并上传视频') |
| 57 | |
| 58 | vid = await this.volcengineService.downloadUrlAndUploadAsStream( |
| 59 | videoInput, |
| 60 | ) |
| 61 | this.logger.log({ vid }, '[VideoStyleTransfer] 视频上传成功') |
| 62 | } |
| 63 | else { |
| 64 | // 假设直接传入的就是 VID |
| 65 | vid = videoInput |
| 66 | } |
| 67 | |
| 68 | const paramObj: AsyncVCreativeTaskParamObj = { |
| 69 | input: vid.startsWith('vid://') ? vid : `vid://${vid}`, |
| 70 | space_name: this.volcengineService.getSpaceName(), |
| 71 | style, |
| 72 | resolution, |
| 73 | } |
| 74 | |
| 75 | const response = await this.volcengineService.asyncVCreativeTask({ |
| 76 | Scene: 'videostyletrans', |
| 77 | Uploader: this.volcengineService.getSpaceName(), |
| 78 | ParamObj: paramObj, |
| 79 | }) |
| 80 | |
| 81 | const taskId = response.VCreativeId |
| 82 | |
| 83 | this.logger.debug({ taskId }, '[VideoStyleTransfer] 任务提交成功') |
| 84 | |
| 85 | await this.aiLogRepo.create({ |
| 86 | userId, |
| 87 | userType: userType as UserType, |
| 88 | taskId, |
| 89 | model: 'video-style-transfer', |
| 90 | channel: AiLogChannel.StyleTransfer, |
| 91 | startedAt, |
| 92 | type: AiLogType.StyleTransfer, |
| 93 | request: { videoInput, style, resolution, vid }, |
| 94 | response: { taskId }, |
| 95 | status: AiLogStatus.Generating, |
| 96 | }) |
| 97 | |
| 98 | return { taskId } |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * 获取视频风格转换任务结果(用户主动查询接口) |
| 103 | * 注意:定时任务会自动处理任务状态,此方法主要用于用户主动查询 |
| 104 | */ |
| 105 | async getVideoStyleTransferTask( |
| 106 | request: UserGetVideoStyleTransferTaskRequest, |
| 107 | ): Promise<{ |
| 108 | taskId: string |
| 109 | status: 'Processing' | 'Completed' | 'Failed' |
| 110 | outputVid?: string |
| 111 | outputUrl?: string |
| 112 | errorMessage?: string |
| 113 | }> { |
| 114 | const { taskId } = request |
| 115 | |
| 116 | this.logger.debug({ taskId }, '[VideoStyleTransfer] 查询任务状态') |
| 117 | |
| 118 | // 从数据库查询任务记录 |
| 119 | const log = await this.aiLogRepo.getByTaskId(taskId) |
| 120 | |
| 121 | if (!log) { |
| 122 | throw new AppException(ResponseCode.AiLogNotFound) |
| 123 | } |
| 124 | |
| 125 | // 如果任务已完成或失败,直接返回数据库中的结果 |
| 126 | if (log.status === AiLogStatus.Success) { |
| 127 | const response = log.response as StyleTransferAiLogResponse | undefined |
| 128 | return { |
| 129 | taskId, |
| 130 | status: 'Completed', |
| 131 | outputVid: response?.outputVid, |
| 132 | outputUrl: response?.outputUrl, |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | if (log.status === AiLogStatus.Failed) { |
| 137 | return { |
| 138 | taskId, |
| 139 | status: 'Failed', |
| 140 | errorMessage: log.errorMessage || '任务执行失败', |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | const result: GetVCreativeTaskResultResponse = await this.volcengineService.getVCreativeTaskResult({ |
| 145 | VCreativeId: taskId, |
| 146 | }) |
| 147 | |
| 148 | // 如果任务已完成,触发处理逻辑 |
| 149 | if (result.Status === VCreativeTaskStatus.Success || result.Status === VCreativeTaskStatus.FailedRun) { |
| 150 | // 调用 processVCreativeTask 处理任务(更新数据库、下载视频、扣费等) |
| 151 | await this.processVCreativeTask(log) |
| 152 | |
| 153 | // 重新查询数据库获取最新状态 |
| 154 | const updatedLog = await this.aiLogRepo.getById(log.id) |
| 155 | if (updatedLog) { |
| 156 | const response = updatedLog.response as StyleTransferAiLogResponse | undefined |
| 157 | return { |
| 158 | taskId, |
| 159 | status: updatedLog.status === AiLogStatus.Success ? 'Completed' : 'Failed', |
| 160 | outputVid: response?.outputVid, |
| 161 | outputUrl: response?.outputUrl, |
| 162 | errorMessage: updatedLog.errorMessage, |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | return { |
| 168 | taskId, |
| 169 | status: 'Processing', |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * 处理 VCreative 任务(视频风格转换) |
| 175 | * 从火山引擎获取结果 |
| 176 | */ |
| 177 | async processVCreativeTask(task: AiLog) { |
| 178 | const taskId = task.taskId |
| 179 | if (!taskId) { |
| 180 | this.logger.warn({ taskId: task.id }, 'VCreative 任务缺少 taskId,跳过处理') |
| 181 | return |
| 182 | } |
| 183 | |
| 184 | try { |
| 185 | const result: GetVCreativeTaskResultResponse = await this.volcengineService.getVCreativeTaskResult({ |
| 186 | VCreativeId: taskId, |
| 187 | }) |
| 188 | |
| 189 | this.logger.debug({ taskId: task.id, status: result.Status }, 'VCreative 任务状态') |
| 190 | |
| 191 | // 处理成功状态 |
| 192 | if (result.Status === VCreativeTaskStatus.Success) { |
| 193 | // 解析输出 VID |
| 194 | let outputVid: string | undefined |
| 195 | if (result.OutputJson) { |
| 196 | try { |
| 197 | const outputJson = JSON.parse(result.OutputJson) |
| 198 | outputVid = outputJson.vid |
| 199 | } |
| 200 | catch (error) { |
| 201 | this.logger.warn({ error }, '[VCreative] 解析 OutputJson 失败') |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // 下载视频并上传到 S3 |
| 206 | let s3Url: string | undefined |
| 207 | if (outputVid) { |
| 208 | try { |
| 209 | this.logger.debug({ outputVid }, '[VCreative] 开始下载视频并上传到 S3') |
| 210 | s3Url = await this.saveVideoFromVid(outputVid, task, 'video-style-transfer') |
| 211 | this.logger.debug({ s3Url }, '[VCreative] 视频已上传到 S3') |
| 212 | } |
| 213 | catch (error) { |
| 214 | this.logger.error({ error }, '[VCreative] 下载或上传 S3 失败') |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // 更新任务状态为成功 |
| 219 | await this.aiLogRepo.updateById(task.id, { |
| 220 | status: AiLogStatus.Success, |
| 221 | response: { |
| 222 | ...result, |
| 223 | outputVid, |
| 224 | outputUrl: s3Url, |
| 225 | }, |
| 226 | duration: Date.now() - task.startedAt.getTime(), |
| 227 | }) |
| 228 | |
| 229 | this.logger.debug({ taskId: task.id, outputVid, s3Url }, '[VCreative] 任务处理完成') |
| 230 | } |
| 231 | // 处理失败状态 |
| 232 | else if (result.Status === VCreativeTaskStatus.FailedRun) { |
| 233 | const errorMessage = result.OutputJson || '任务执行失败' |
| 234 | |
| 235 | this.logger.error({ taskId: task.id, errorMessage }, '[VCreative] 任务失败') |
| 236 | |
| 237 | await this.aiLogRepo.updateById(task.id, { |
| 238 | status: AiLogStatus.Failed, |
| 239 | response: result, |
| 240 | errorMessage, |
| 241 | }) |
| 242 | } |
| 243 | // 处理中状态 - 不做任何操作,等待下次轮询 |
| 244 | else if (result.Status === VCreativeTaskStatus.Processing) { |
| 245 | this.logger.debug({ taskId: task.id }, '[VCreative] 任务处理中') |
| 246 | // 不更新数据库,保持 Generating 状态 |
| 247 | } |
| 248 | } |
| 249 | catch (error) { |
| 250 | this.logger.error({ error, taskId: task.id, volcengineTaskId: taskId }, '获取 VCreative 任务结果失败') |
| 251 | |
| 252 | const resp = isAxiosError(error) |
| 253 | ? (error.response?.data ?? error.response) |
| 254 | : undefined |
| 255 | |
| 256 | await this.aiLogRepo.updateById(task.id, { |
| 257 | status: AiLogStatus.Failed, |
| 258 | response: resp ? (typeof resp === 'object' ? resp : { message: String(resp) }) : undefined, |
| 259 | errorMessage: getErrorMessage(error), |
| 260 | }) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * 从 VID 获取视频并上传 |
| 266 | */ |
| 267 | private async saveVideoFromVid( |
| 268 | vid: string, |
| 269 | task: AiLog, |
| 270 | filenamePrefix: string, |
| 271 | ): Promise<string | undefined> { |
| 272 | return VolcengineVideoUtils.saveVideoFromVid( |
| 273 | vid, |
| 274 | task.userId, |
| 275 | `${task.id}-${filenamePrefix}`, |
| 276 | task.model || 'video-style-transfer', |
| 277 | this.volcengineService, |
| 278 | this.assetsService, |
| 279 | this.logger, |
| 280 | AssetType.AideoOutput, |
| 281 | ) |
| 282 | } |
| 283 | } |
| 284 |