返回 AiToEarn
video.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / video / video.service.ts
1 import { Injectable, Logger, Optional } from '@nestjs/common'
2 import { AssetsService, VideoMetadataService } from '@yikart/assets'
3 import { AppException, FileUtil, ResponseCode } from '@yikart/common'
4 import {
5 AiLogChannel,
6 AiLogRepository,
7 AiLogStatus,
8 AiLogType,
9 AssetType,
10 MaterialGroupRepository,
11 MediaRepository,
12 MediaType,
13 UserRepository,
14 } from '@yikart/mongodb'
15 import { TaskStatus } from '../../../common'
16 import { ModelsConfigService } from '../models-config'
17 import { DashscopeVideoService } from './dashscope'
18 import { GrokVideoService } from './grok'
19 import { OpenAIVideoService } from './openai'
20 import { RelayVideoService } from './relay/relay-video.service'
21 import { VideoAiLog } from './video-ai-log.interface'
22 import {
23 UserListVideoTasksQueryDto,
24 UserVideoGenerationRequestDto,
25 UserVideoTaskQueryDto,
26 VideoGenerationModelsQueryDto,
27 } from './video.dto'
28 import { VideoTaskInput } from './video.vo'
29 import { VolcengineVideoService } from './volcengine/volcengine.service'
30
31 @Injectable()
32 export class VideoService {
33 private readonly logger = new Logger(VideoService.name)
34
35 constructor(
36 private readonly userRepo: UserRepository,
37 private readonly aiLogRepo: AiLogRepository,
38 private readonly modelsConfigService: ModelsConfigService,
39 private readonly assetsService: AssetsService,
40 private readonly videoMetadataService: VideoMetadataService,
41 private readonly materialGroupRepository: MaterialGroupRepository,
42 private readonly mediaRepository: MediaRepository,
43 private readonly volcengineVideoService: VolcengineVideoService,
44 private readonly openaiVideoService: OpenAIVideoService,
45 private readonly grokVideoService: GrokVideoService,
46 private readonly dashscopeVideoService: DashscopeVideoService,
47 @Optional() private readonly relayVideoService?: RelayVideoService,
48 ) {}
49
50 private requireChannel<T>(service: T | undefined): T {
51 if (!service) {
52 throw new AppException(ResponseCode.InvalidModel)
53 }
54 return service
55 }
56
57 /**
58 * 用户视频生成(通用接口)
59 */
60 async userVideoGeneration(request: UserVideoGenerationRequestDto) {
61 const { model, groupId, userId } = request
62
63 if (groupId) {
64 const group = await this.materialGroupRepository.getInfo(groupId)
65 if (!group || group.userId !== userId) {
66 throw new AppException(ResponseCode.MaterialGroupNotFound)
67 }
68 }
69
70 const modelConfig = this.modelsConfigService.config.video.generation.find(m => m.name === model)
71 if (!modelConfig) {
72 throw new AppException(ResponseCode.InvalidModel)
73 }
74 if (request.mode && !(modelConfig.modes as readonly string[]).includes(request.mode)) {
75 throw new AppException(ResponseCode.InvalidModel)
76 }
77
78 let response: { id: string }
79
80 switch (modelConfig.channel) {
81 case AiLogChannel.Volcengine:
82 response = await this.volcengineVideoService.createFromRequest(request)
83 break
84 case AiLogChannel.OpenAI:
85 response = await this.openaiVideoService.createFromRequest(request)
86 break
87 case AiLogChannel.Grok:
88 response = await this.grokVideoService.createFromRequest(request)
89 break
90 case AiLogChannel.Dashscope:
91 response = await this.dashscopeVideoService.createFromRequest(request)
92 break
93 case AiLogChannel.Relay:
94 response = await this.requireChannel(this.relayVideoService).createFromRequest(request)
95 break
96 default:
97 throw new AppException(ResponseCode.InvalidModel)
98 }
99
100 if (groupId) {
101 await this.aiLogRepo.updateById(response.id, {
102 $set: {
103 'request.groupId': groupId,
104 },
105 })
106 }
107
108 return {
109 id: response.id,
110 status: TaskStatus.Submitted,
111 }
112 }
113
114 private extractInput(aiLog: VideoAiLog): VideoTaskInput {
115 let input: VideoTaskInput
116 switch (aiLog.channel) {
117 case AiLogChannel.Volcengine:
118 input = this.volcengineVideoService.extractInput(aiLog.request)
119 break
120 case AiLogChannel.OpenAI:
121 input = this.openaiVideoService.extractInput(aiLog.request)
122 break
123 case AiLogChannel.Grok:
124 input = this.grokVideoService.extractInput(aiLog.request)
125 break
126 case AiLogChannel.Dashscope:
127 input = this.dashscopeVideoService.extractInput(aiLog.request)
128 break
129 case AiLogChannel.Relay:
130 input = this.requireChannel(this.relayVideoService).extractInput(aiLog.request)
131 break
132 default:
133 input = { prompt: '' }
134 break
135 }
136
137 return {
138 ...input,
139 groupId: aiLog.request.groupId,
140 }
141 }
142
143 async transformToCommonResponse(aiLog: VideoAiLog) {
144 const input = this.extractInput(aiLog)
145 const savedMedia = await this.ensureSavedVideoMedia(aiLog)
146
147 const base = {
148 id: aiLog.id,
149 model: aiLog.model,
150 input,
151 submittedAt: aiLog.startedAt,
152 startedAt: aiLog.startedAt,
153 }
154
155 if (aiLog.status === AiLogStatus.Generating) {
156 return {
157 ...base,
158 status: TaskStatus.InProgress,
159 videoUrl: undefined,
160 coverUrl: savedMedia.coverUrl ? FileUtil.buildUrl(savedMedia.coverUrl) : undefined,
161 mediaId: savedMedia.mediaId,
162 groupId: savedMedia.groupId,
163 error: undefined,
164 finishedAt: undefined,
165 }
166 }
167
168 const finishedAt = aiLog.duration
169 ? new Date(aiLog.startedAt.getTime() + aiLog.duration)
170 : undefined
171
172 if (aiLog.status === AiLogStatus.Failed) {
173 return {
174 ...base,
175 status: TaskStatus.Failure,
176 videoUrl: undefined,
177 coverUrl: savedMedia.coverUrl ? FileUtil.buildUrl(savedMedia.coverUrl) : undefined,
178 mediaId: savedMedia.mediaId,
179 groupId: savedMedia.groupId,
180 error: { message: aiLog.errorMessage ?? 'Video task failed' },
181 finishedAt,
182 }
183 }
184
185 if (!aiLog.response) {
186 throw new AppException(ResponseCode.InvalidAiTaskId)
187 }
188
189 const channelResult = this.getChannelTaskResult(aiLog)
190
191 return {
192 ...base,
193 ...channelResult,
194 coverUrl: savedMedia.coverUrl ? FileUtil.buildUrl(savedMedia.coverUrl) : undefined,
195 mediaId: savedMedia.mediaId,
196 groupId: savedMedia.groupId,
197 finishedAt,
198 }
199 }
200
201 async ensureSavedMediaByAiLogId(aiLogId: string): Promise<void> {
202 const aiLog = await this.aiLogRepo.getById(aiLogId)
203 if (!aiLog || aiLog.type !== AiLogType.Video) {
204 return
205 }
206
207 switch (aiLog.channel) {
208 case AiLogChannel.Volcengine:
209 case AiLogChannel.OpenAI:
210 case AiLogChannel.Grok:
211 case AiLogChannel.Dashscope:
212 case AiLogChannel.Relay:
213 await this.ensureSavedVideoMedia(aiLog as VideoAiLog)
214 }
215 }
216
217 private getChannelTaskResult(aiLog: VideoAiLog) {
218 if (!aiLog.response) {
219 throw new AppException(ResponseCode.InvalidAiTaskId)
220 }
221
222 switch (aiLog.channel) {
223 case AiLogChannel.Volcengine:
224 return this.volcengineVideoService.getTaskResult(aiLog.response)
225 case AiLogChannel.OpenAI:
226 return this.openaiVideoService.getTaskResult(aiLog.response)
227 case AiLogChannel.Grok:
228 return this.grokVideoService.getTaskResult(aiLog.response)
229 case AiLogChannel.Dashscope:
230 return this.dashscopeVideoService.getTaskResult(aiLog.response)
231 case AiLogChannel.Relay:
232 return this.requireChannel(this.relayVideoService).getTaskResult(aiLog.response)
233 default:
234 throw new AppException(ResponseCode.InvalidAiTaskId)
235 }
236 }
237
238 /**
239 * 查询视频任务状态
240 */
241 async getVideoTaskStatus(request: UserVideoTaskQueryDto) {
242 const { taskId } = request
243
244 const aiLog = await this.aiLogRepo.getById(taskId)
245
246 if (aiLog == null || aiLog.type !== AiLogType.Video) {
247 throw new AppException(ResponseCode.InvalidAiTaskId)
248 }
249
250 switch (aiLog.channel) {
251 case AiLogChannel.Volcengine:
252 case AiLogChannel.OpenAI:
253 case AiLogChannel.Grok:
254 case AiLogChannel.Dashscope:
255 case AiLogChannel.Relay:
256 return this.transformToCommonResponse(aiLog as VideoAiLog)
257 default:
258 throw new AppException(ResponseCode.InvalidAiTaskId)
259 }
260 }
261
262 async listVideoTasks(request: UserListVideoTasksQueryDto) {
263 const [aiLogs, count] = await this.aiLogRepo.listWithPagination({
264 ...request,
265 type: AiLogType.Video,
266 channels: [
267 AiLogChannel.Volcengine,
268 AiLogChannel.OpenAI,
269 AiLogChannel.Grok,
270 AiLogChannel.Dashscope,
271 AiLogChannel.Relay,
272 ],
273 })
274
275 return [
276 await Promise.all(aiLogs.map((log) => {
277 switch (log.channel) {
278 case AiLogChannel.Volcengine:
279 case AiLogChannel.OpenAI:
280 case AiLogChannel.Grok:
281 case AiLogChannel.Dashscope:
282 case AiLogChannel.Relay:
283 return this.transformToCommonResponse(log as VideoAiLog)
284 default:
285 throw new AppException(ResponseCode.InvalidAiTaskId)
286 }
287 })),
288 count,
289 ] as const
290 }
291
292 /**
293 * 获取视频生成模型参数
294 */
295 async getVideoGenerationModelParams(_data: VideoGenerationModelsQueryDto) {
296 return this.modelsConfigService.config.video.generation
297 }
298
299 private async ensureSavedVideoMedia(aiLog: VideoAiLog): Promise<{ mediaId?: string, coverUrl?: string, groupId?: string }> {
300 const response = aiLog.response
301 const request = aiLog.request
302 const existingMediaId = response?.mediaId
303 const existingCoverUrl = response?.coverUrl
304
305 if (existingMediaId) {
306 return {
307 mediaId: existingMediaId,
308 coverUrl: existingCoverUrl,
309 groupId: response?.groupId ?? request.groupId,
310 }
311 }
312
313 if (aiLog.status !== AiLogStatus.Success || !response) {
314 return {}
315 }
316
317 const targetGroupId = response.groupId ?? request.groupId
318 if (!targetGroupId) {
319 return {
320 coverUrl: existingCoverUrl,
321 }
322 }
323
324 try {
325 const commonResult = this.getChannelTaskResult(aiLog)
326 if (!commonResult.videoUrl) {
327 this.logger.warn({ aiLogId: aiLog.id, channel: aiLog.channel }, 'Video task succeeded but video path is missing')
328 return {}
329 }
330 const videoPath = FileUtil.trimHost(commonResult.videoUrl)
331
332 let coverPath = existingCoverUrl
333 if (!coverPath) {
334 try {
335 const thumbnailBuffer = await this.videoMetadataService.extractThumbnailFromUrl(commonResult.videoUrl, 2)
336 const uploadResult = await this.assetsService.uploadFromBuffer(aiLog.userId, thumbnailBuffer, {
337 type: AssetType.VideoThumbnail,
338 mimeType: 'image/png',
339 filename: 'thumbnail.png',
340 })
341 coverPath = uploadResult.asset.path
342 }
343 catch (error) {
344 this.logger.warn({ error, aiLogId: aiLog.id }, 'Failed to generate thumbnail for saved video media')
345 }
346 }
347
348 const media = await this.mediaRepository.create({
349 userId: aiLog.userId,
350 userType: aiLog.userType,
351 materialGroupId: targetGroupId,
352 type: MediaType.VIDEO,
353 url: videoPath,
354 thumbUrl: coverPath,
355 })
356
357 await this.aiLogRepo.updateById(aiLog.id, {
358 $set: {
359 response: {
360 ...response,
361 mediaId: media.id,
362 groupId: targetGroupId,
363 ...(coverPath ? { coverUrl: coverPath } : {}),
364 },
365 },
366 })
367
368 return {
369 mediaId: media.id,
370 coverUrl: coverPath,
371 groupId: targetGroupId,
372 }
373 }
374 catch (error) {
375 this.logger.warn({ error, aiLogId: aiLog.id, groupId: targetGroupId }, 'Failed to save generated video to material')
376 return {}
377 }
378 }
379 }
380
380 lines TYPESCRIPT