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