返回 AiToEarn
video-style-transfer.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / aideo / video-style-transfer.service.ts
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
284 lines TYPESCRIPT