| 1 | import type { GrokVideoAiLog } from '../video-ai-log.interface' |
| 2 | import type { UserVideoGenerationRequestDto } from '../video.dto' |
| 3 | import { Injectable, Logger } from '@nestjs/common' |
| 4 | import { AssetsService, StorageProvider, VideoMetadataService } from '@yikart/assets' |
| 5 | import { AppException, FileUtil, ResponseCode, UserType } from '@yikart/common' |
| 6 | import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType, AssetType } from '@yikart/mongodb' |
| 7 | import { AxiosError } from 'axios' |
| 8 | import { TaskStatus } from '../../../../common' |
| 9 | import { config } from '../../../../config' |
| 10 | import { AiAvailabilityService } from '../../../ai-availability/ai-availability.service' |
| 11 | import { GrokAspectRatio, GrokGetVideoStatusResponse, GrokLibService, GrokResolution, GrokVideoTaskStatus } from '../../libs/grok' |
| 12 | |
| 13 | export interface GrokVideoCreateRequest { |
| 14 | userId: string |
| 15 | userType: UserType |
| 16 | model: string |
| 17 | prompt: string |
| 18 | duration?: number |
| 19 | aspectRatio?: string |
| 20 | resolution?: string |
| 21 | image?: string |
| 22 | referenceImages?: string[] |
| 23 | videoUrl?: string |
| 24 | } |
| 25 | |
| 26 | export interface GrokVideoCallbackDto { |
| 27 | status: GrokVideoTaskStatus |
| 28 | videoUrl?: string |
| 29 | error?: string |
| 30 | } |
| 31 | |
| 32 | @Injectable() |
| 33 | export class GrokVideoService { |
| 34 | private readonly logger = new Logger(GrokVideoService.name) |
| 35 | |
| 36 | constructor( |
| 37 | private readonly grokLibService: GrokLibService, |
| 38 | private readonly aiLogRepo: AiLogRepository, |
| 39 | private readonly assetsService: AssetsService, |
| 40 | private readonly storageProvider: StorageProvider, |
| 41 | private readonly videoMetadataService: VideoMetadataService, |
| 42 | private readonly aiAvailability: AiAvailabilityService, |
| 43 | ) { } |
| 44 | |
| 45 | private async toAccessibleUrl(url: string | undefined): Promise<string | undefined> { |
| 46 | if (!url) { |
| 47 | return undefined |
| 48 | } |
| 49 | const parsed = this.storageProvider.parsePathFromUrl(url) |
| 50 | if (parsed.startsWith('http')) { |
| 51 | return url |
| 52 | } |
| 53 | return this.storageProvider.toPresignedUrl(url) |
| 54 | } |
| 55 | |
| 56 | async createFromRequest(request: UserVideoGenerationRequestDto): Promise<{ id: string }> { |
| 57 | const videoUrl = await this.toAccessibleUrl(request.video_url) |
| 58 | const image = Array.isArray(request.image) |
| 59 | ? undefined |
| 60 | : await this.toAccessibleUrl(request.image) |
| 61 | const referenceImageUrls = [ |
| 62 | ...(Array.isArray(request.image) ? request.image : []), |
| 63 | ...(request.images ?? []), |
| 64 | ] |
| 65 | const referenceImages = referenceImageUrls.length > 0 |
| 66 | ? await Promise.all(referenceImageUrls.map(url => this.toAccessibleUrl(url) as Promise<string>)) |
| 67 | : undefined |
| 68 | const result = await this.createVideo({ |
| 69 | userId: request.userId, |
| 70 | userType: request.userType, |
| 71 | model: request.model, |
| 72 | prompt: request.prompt, |
| 73 | duration: request.duration, |
| 74 | aspectRatio: request.metadata?.['aspectRatio'] as string, |
| 75 | resolution: request.metadata?.['resolution'] as string, |
| 76 | image, |
| 77 | referenceImages, |
| 78 | videoUrl, |
| 79 | }) |
| 80 | |
| 81 | return { id: result.id } |
| 82 | } |
| 83 | |
| 84 | private resolveVideoEditDuration(requestedDuration?: number, metadataDuration?: number): number | undefined { |
| 85 | const normalizedMetadataDuration = Number(metadataDuration) |
| 86 | if (Number.isFinite(normalizedMetadataDuration) && normalizedMetadataDuration > 0) { |
| 87 | return Math.ceil(normalizedMetadataDuration) |
| 88 | } |
| 89 | |
| 90 | return requestedDuration |
| 91 | } |
| 92 | |
| 93 | async createVideo(request: GrokVideoCreateRequest) { |
| 94 | const { userId, userType, model, prompt, duration, aspectRatio, resolution, image, referenceImages, videoUrl } = request |
| 95 | |
| 96 | let resolvedDuration = duration |
| 97 | if (videoUrl) { |
| 98 | const metadata = await this.videoMetadataService.probeVideoMetadata(videoUrl) |
| 99 | resolvedDuration = this.resolveVideoEditDuration(duration, metadata.duration) |
| 100 | } |
| 101 | |
| 102 | const startedAt = new Date() |
| 103 | |
| 104 | const result = await this.aiAvailability.executeAsync( |
| 105 | { provider: 'grok', operation: 'videoGeneration', model }, |
| 106 | () => videoUrl |
| 107 | ? this.grokLibService.editVideo({ |
| 108 | model, |
| 109 | prompt, |
| 110 | video: { url: videoUrl }, |
| 111 | }) |
| 112 | : this.grokLibService.createVideo({ |
| 113 | model, |
| 114 | prompt, |
| 115 | duration: resolvedDuration, |
| 116 | aspect_ratio: aspectRatio as GrokAspectRatio, |
| 117 | resolution: resolution as GrokResolution, |
| 118 | image: image ? { url: image } : undefined, |
| 119 | reference_images: referenceImages?.length ? referenceImages.map(url => ({ url })) : undefined, |
| 120 | }), |
| 121 | r => r.request_id, |
| 122 | ) |
| 123 | |
| 124 | const aiLog = await this.aiLogRepo.create({ |
| 125 | userId, |
| 126 | userType, |
| 127 | taskId: result.request_id, |
| 128 | model, |
| 129 | channel: AiLogChannel.Grok, |
| 130 | startedAt, |
| 131 | type: AiLogType.Video, |
| 132 | request: { model, prompt, duration: resolvedDuration, aspectRatio, resolution, image, referenceImages, videoUrl }, |
| 133 | status: AiLogStatus.Generating, |
| 134 | }) |
| 135 | |
| 136 | return { |
| 137 | id: aiLog.id, |
| 138 | requestId: result.request_id, |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * 回调处理:根据 Grok API 查询结果更新 AiLog,上传视频,失败退款 |
| 144 | */ |
| 145 | async callback(result: GrokGetVideoStatusResponse, aiLog: GrokVideoAiLog): Promise<GrokVideoCallbackDto> { |
| 146 | if (aiLog.status !== AiLogStatus.Generating) { |
| 147 | return aiLog.response! |
| 148 | } |
| 149 | |
| 150 | this.logger.log({ result, aiLogId: aiLog.id }, 'Grok callback') |
| 151 | |
| 152 | if (result.video?.url) { |
| 153 | const downloadUrl = config.ai.grok?.proxyUrl |
| 154 | ? `${config.ai.grok.proxyUrl}/${result.video.url}` |
| 155 | : result.video.url |
| 156 | |
| 157 | const uploaded = await this.assetsService.uploadFromUrl(aiLog.userId, { |
| 158 | url: downloadUrl, |
| 159 | type: AssetType.AiVideo, |
| 160 | }, aiLog.model) |
| 161 | |
| 162 | const elapsedMs = Date.now() - aiLog.startedAt.getTime() |
| 163 | const callbackData: GrokVideoCallbackDto = { |
| 164 | status: GrokVideoTaskStatus.Done, |
| 165 | videoUrl: uploaded.asset.path, |
| 166 | } |
| 167 | |
| 168 | const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus( |
| 169 | aiLog.id, |
| 170 | AiLogStatus.Generating, |
| 171 | { |
| 172 | $set: { |
| 173 | status: AiLogStatus.Success, |
| 174 | response: callbackData, |
| 175 | duration: elapsedMs, |
| 176 | }, |
| 177 | }, |
| 178 | ) |
| 179 | |
| 180 | if (!updatedAiLog) { |
| 181 | return aiLog.response! |
| 182 | } |
| 183 | |
| 184 | await this.aiAvailability.recordAsyncComplete( |
| 185 | aiLog.taskId!, |
| 186 | { provider: 'grok', operation: 'videoGeneration', model: aiLog.model }, |
| 187 | { success: true, latencyMs: elapsedMs }, |
| 188 | ) |
| 189 | |
| 190 | return callbackData |
| 191 | } |
| 192 | |
| 193 | const isTerminal = result.status === GrokVideoTaskStatus.Done |
| 194 | || result.status === GrokVideoTaskStatus.Failed |
| 195 | || result.status === GrokVideoTaskStatus.Expired |
| 196 | |
| 197 | if (isTerminal) { |
| 198 | const errorMessage = result.status === GrokVideoTaskStatus.Done |
| 199 | ? 'Video generation completed but no video URL returned' |
| 200 | : result.status === GrokVideoTaskStatus.Expired |
| 201 | ? 'Video generation task expired' |
| 202 | : (result.error?.message || 'Video generation failed') |
| 203 | |
| 204 | const elapsedMs = Date.now() - aiLog.startedAt.getTime() |
| 205 | const callbackData: GrokVideoCallbackDto = { |
| 206 | status: GrokVideoTaskStatus.Failed, |
| 207 | error: errorMessage, |
| 208 | } |
| 209 | |
| 210 | const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus(aiLog.id, AiLogStatus.Generating, { |
| 211 | $set: { |
| 212 | status: AiLogStatus.Failed, |
| 213 | response: callbackData, |
| 214 | duration: elapsedMs, |
| 215 | errorMessage, |
| 216 | }, |
| 217 | }) |
| 218 | |
| 219 | if (!updatedAiLog) { |
| 220 | return aiLog.response! |
| 221 | } |
| 222 | |
| 223 | const isContentModeration = errorMessage.toLowerCase().includes('content moderation') |
| 224 | |
| 225 | await this.aiAvailability.recordAsyncComplete( |
| 226 | aiLog.taskId!, |
| 227 | { provider: 'grok', operation: 'videoGeneration', model: aiLog.model }, |
| 228 | { success: false, latencyMs: elapsedMs, errorMessage, isBusinessError: isContentModeration }, |
| 229 | ) |
| 230 | |
| 231 | return callbackData |
| 232 | } |
| 233 | |
| 234 | return { status: result.status ?? GrokVideoTaskStatus.Pending } |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * 将回调数据转为统一的任务结果格式 |
| 239 | */ |
| 240 | getTaskResult(result: GrokVideoCallbackDto) { |
| 241 | const status = { |
| 242 | [GrokVideoTaskStatus.Done]: TaskStatus.Success, |
| 243 | [GrokVideoTaskStatus.Failed]: TaskStatus.Failure, |
| 244 | [GrokVideoTaskStatus.Expired]: TaskStatus.Failure, |
| 245 | [GrokVideoTaskStatus.Pending]: TaskStatus.InProgress, |
| 246 | }[result.status] |
| 247 | |
| 248 | return { |
| 249 | status, |
| 250 | videoUrl: result.videoUrl ? FileUtil.buildUrl(result.videoUrl) : undefined, |
| 251 | error: result.error ? { message: result.error } : undefined, |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | extractInput(request: GrokVideoAiLog['request']) { |
| 256 | return { |
| 257 | prompt: request.prompt || '', |
| 258 | image: request.referenceImages ?? request.image, |
| 259 | duration: request.duration, |
| 260 | aspectRatio: request.aspectRatio, |
| 261 | resolution: request.resolution, |
| 262 | videoUrl: request.videoUrl, |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * 用户查询任务状态(含实时查询 Grok API) |
| 268 | */ |
| 269 | async getTask(userId: string, userType: UserType, logId: string): Promise<GrokVideoCallbackDto> { |
| 270 | const aiLog = await this.aiLogRepo.getByIdAndUserId(logId, userId, userType) |
| 271 | |
| 272 | if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Grok) { |
| 273 | throw new AppException(ResponseCode.InvalidAiTaskId) |
| 274 | } |
| 275 | const grokAiLog = aiLog as GrokVideoAiLog |
| 276 | |
| 277 | if (grokAiLog.status !== AiLogStatus.Generating) { |
| 278 | return grokAiLog.response! |
| 279 | } |
| 280 | try { |
| 281 | const result = await this.grokLibService.getVideoStatus(grokAiLog.taskId!) |
| 282 | return await this.callback(result, grokAiLog) |
| 283 | } |
| 284 | catch (e) { |
| 285 | let errorMessage: string = (e as Error).message |
| 286 | let code = '500' |
| 287 | if (e instanceof AxiosError) { |
| 288 | const status = e?.response?.status |
| 289 | if (status && status >= 400 && status < 500) { |
| 290 | const data = e.response?.data |
| 291 | errorMessage = data?.error || data?.code || `Grok API error (${status})` |
| 292 | code = data?.code || `HTTP_${status}` |
| 293 | } |
| 294 | } |
| 295 | return await this.callback({ |
| 296 | status: GrokVideoTaskStatus.Failed, |
| 297 | error: { code, message: errorMessage }, |
| 298 | }, grokAiLog) |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 |