| 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 |