返回 AiToEarn
openai.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / video / openai / openai.service.ts
1 import type { OpenAIVideoAiLog } from '../video-ai-log.interface'
2 import type { UserVideoGenerationRequestDto } from '../video.dto'
3 import { BadRequestException, Injectable, Logger, Optional } from '@nestjs/common'
4 import { AssetsService, StorageProvider } from '@yikart/assets'
5 import { AppException, FileUtil, ResponseCode, UserType } from '@yikart/common'
6 import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType, AssetType } from '@yikart/mongodb'
7 import { TaskStatus } from '../../../../common'
8 import { AiAvailabilityService } from '../../../ai-availability/ai-availability.service'
9 import { OpenaiService as OpenaiLibService } from '../../libs/openai'
10 import { RelayMediaResolverService } from '../../relay-media'
11 import {
12 OpenAIVideoCallbackDto,
13 SoraCharacterCallbackDto,
14 UserOpenAIVideoCreateRequestDto,
15 UserOpenAIVideoRemixRequestDto,
16 UserSoraCharacterCreateRequestDto,
17 } from './openai.dto'
18
19 @Injectable()
20 export class OpenAIVideoService {
21 private readonly logger = new Logger(OpenAIVideoService.name)
22
23 constructor(
24 private readonly openaiLibService: OpenaiLibService,
25 private readonly aiLogRepo: AiLogRepository,
26 private readonly assetsService: AssetsService,
27 private readonly storageProvider: StorageProvider,
28 private readonly aiAvailability: AiAvailabilityService,
29 @Optional() private readonly relayMediaResolver?: RelayMediaResolverService,
30 ) {}
31
32 private async toAccessibleUrl(url: string | undefined): Promise<string | undefined> {
33 if (!url) {
34 return undefined
35 }
36 const parsed = this.storageProvider.parsePathFromUrl(url)
37 if (parsed.startsWith('http')) {
38 return url
39 }
40 return this.storageProvider.toPresignedUrl(url)
41 }
42
43 private async resolveRelayText(text: string): Promise<string> {
44 if (!this.relayMediaResolver) {
45 return text
46 }
47 return await this.relayMediaResolver.resolveText(text)
48 }
49
50 async createFromRequest(request: UserVideoGenerationRequestDto): Promise<{ id: string }> {
51 if (Array.isArray(request.image)) {
52 throw new BadRequestException('OpenAI does not support multiple images')
53 }
54
55 const result = await this.createVideo({
56 userId: request.userId,
57 userType: request.userType,
58 prompt: request.prompt,
59 input_reference: await this.toAccessibleUrl(request.image),
60 model: request.model as 'sora-2' | 'sora-2-pro',
61 seconds: request.duration ? request.duration.toString() as '10' | '15' | '25' : undefined,
62 size: request.size as '720x1280' | '1280x720' | '1024x1792' | '1792x1024' | undefined,
63 })
64
65 return { id: result.id }
66 }
67
68 /**
69 * OpenAI 视频创建
70 */
71 async createVideo(request: UserOpenAIVideoCreateRequestDto) {
72 const { userId, userType, model, prompt, input_reference, seconds, size } = request
73
74 const startedAt = new Date()
75
76 // 如果 input_reference 是 URL,需要先 fetch 后传入 Response
77 let inputReferenceUploadable: Response | undefined
78 if (input_reference) {
79 const resolvedInputReference = await this.resolveRelayText(input_reference)
80 const response = await fetch(resolvedInputReference)
81 if (!response.ok) {
82 throw new AppException(ResponseCode.S3DownloadFileFailed)
83 }
84 inputReferenceUploadable = response
85 }
86
87 const result = await this.aiAvailability.executeAsync(
88 { provider: 'openai', operation: 'videoGeneration', model: model || 'sora-2' },
89 () => this.openaiLibService.createVideo({
90 prompt,
91 input_reference: inputReferenceUploadable,
92 model: model as 'sora-2' | 'sora-2-pro',
93 // SDK 类型定义有误,实际支持 '10' | '15' | '25'
94 seconds: seconds as '4' | '8' | '12' | undefined,
95 size,
96 }),
97 r => r.id,
98 )
99
100 const aiLog = await this.aiLogRepo.create({
101 userId,
102 userType,
103 taskId: result.id,
104 model: model || 'sora-2',
105 channel: AiLogChannel.OpenAI,
106 startedAt,
107 type: AiLogType.Video,
108 request: {
109 prompt,
110 input_reference,
111 model,
112 seconds,
113 size,
114 },
115 status: AiLogStatus.Generating,
116 })
117
118 return {
119 ...result,
120 id: aiLog.id,
121 }
122 }
123
124 /**
125 * OpenAI 视频 Remix
126 */
127 async remixVideo(request: UserOpenAIVideoRemixRequestDto) {
128 const { userId, userType, videoId, prompt } = request
129
130 // 首先查找原视频任务
131 const aiLog = await this.aiLogRepo.getByIdAndUserId(videoId, userId, userType)
132 if (!aiLog || aiLog.channel !== AiLogChannel.OpenAI || !aiLog.taskId) {
133 throw new AppException(ResponseCode.InvalidAiTaskId)
134 }
135
136 const model = aiLog.model
137
138 const startedAt = new Date()
139 const result = await this.aiAvailability.executeAsync(
140 { provider: 'openai', operation: 'videoGeneration', model },
141 () => this.openaiLibService.remixVideo(aiLog.taskId!, prompt),
142 r => r.id,
143 )
144
145 const newAiLog = await this.aiLogRepo.create({
146 userId,
147 userType,
148 taskId: result.id,
149 model,
150 channel: AiLogChannel.OpenAI,
151 startedAt,
152 type: AiLogType.Video,
153 request: {
154 prompt,
155 remixed_from_video_id: aiLog.taskId,
156 },
157 status: AiLogStatus.Generating,
158 })
159
160 return {
161 ...result,
162 id: newAiLog.id,
163 }
164 }
165
166 /**
167 * OpenAI回调处理
168 */
169 async callback(data: OpenAIVideoCallbackDto) {
170 const { id, status } = data
171
172 const aiLog = await this.aiLogRepo.getByTaskId(id)
173 if (!aiLog || aiLog.channel !== AiLogChannel.OpenAI) {
174 throw new AppException(ResponseCode.InvalidAiTaskId)
175 }
176 const openAiLog = aiLog as OpenAIVideoAiLog
177
178 if (openAiLog.status !== AiLogStatus.Generating && status !== 'completed' && status !== 'failed') {
179 return
180 }
181
182 this.logger.debug({
183 taskId: data.id,
184 status: data.status,
185 }, `OpenAI callback`)
186
187 let aiLogStatus: AiLogStatus
188 switch (status) {
189 case 'completed':
190 aiLogStatus = AiLogStatus.Success
191 break
192 case 'failed':
193 aiLogStatus = AiLogStatus.Failed
194 break
195 default:
196 aiLogStatus = AiLogStatus.Generating
197 break
198 }
199
200 // 处理视频下载
201 if (aiLogStatus === AiLogStatus.Success) {
202 // 优先使用第三方提供的 url 或 video_url
203 let videoUrl = data.url || data.video_url
204
205 // 如果没有直接的 URL,则使用 downloadContent
206 if (!videoUrl) {
207 const response = await this.openaiLibService.downloadVideoContent(id, 'video')
208 if (!response.body) {
209 throw new AppException(ResponseCode.S3DownloadFileFailed)
210 }
211 const buffer = Buffer.from(await response.arrayBuffer())
212 const uploadResult = await this.assetsService.uploadFromBuffer(openAiLog.userId, buffer, {
213 type: AssetType.AiVideo,
214 mimeType: 'video/mp4',
215 }, `${openAiLog.model}`)
216 videoUrl = uploadResult.asset.path
217 }
218 else {
219 // 如果有直接的 URL,保存到 S3
220 const uploadResult = await this.assetsService.uploadFromUrl(openAiLog.userId, {
221 url: videoUrl,
222 type: AssetType.AiVideo,
223 }, `${openAiLog.model}`)
224 videoUrl = uploadResult.asset.path
225 }
226
227 // 更新 data 中的 URL
228 data.url = videoUrl
229 data.video_url = videoUrl
230 }
231
232 const duration = data.completed_at ? (data.completed_at * 1000) - openAiLog.startedAt.getTime() : Date.now() - openAiLog.startedAt.getTime()
233
234 const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus(openAiLog.id, AiLogStatus.Generating, {
235 $set: {
236 status: aiLogStatus,
237 response: data,
238 duration,
239 errorMessage: status === 'failed' ? data.error?.message : undefined,
240 },
241 })
242
243 if (!updatedAiLog) {
244 return
245 }
246
247 if (aiLogStatus === AiLogStatus.Success || aiLogStatus === AiLogStatus.Failed) {
248 await this.aiAvailability.recordAsyncComplete(
249 id,
250 { provider: 'openai', operation: 'videoGeneration', model: openAiLog.model },
251 {
252 success: aiLogStatus === AiLogStatus.Success,
253 latencyMs: duration,
254 errorMessage: status === 'failed' ? data.error?.message : undefined,
255 },
256 )
257 }
258 }
259
260 /**
261 * 查询OpenAI任务状态
262 */
263 async getVideo(userId: string, userType: UserType, videoId: string): Promise<OpenAIVideoCallbackDto> {
264 const aiLog = await this.aiLogRepo.getByIdAndUserId(videoId, userId, userType)
265
266 if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.OpenAI) {
267 throw new AppException(ResponseCode.InvalidAiTaskId)
268 }
269 const openAiLog = aiLog as OpenAIVideoAiLog
270
271 return openAiLog.response!
272 }
273
274 /**
275 * 查询OpenAI任务结果
276 */
277 getTaskResult(result: OpenAIVideoCallbackDto) {
278 const status = {
279 queued: TaskStatus.Submitted,
280 in_progress: TaskStatus.InProgress,
281 completed: TaskStatus.Success,
282 failed: TaskStatus.Failure,
283 }[result.status]
284
285 const rawUrl = result.url || result.video_url
286 return {
287 status,
288 videoUrl: rawUrl ? FileUtil.buildUrl(rawUrl) : undefined,
289 error: result.error ? { message: result.error.message } : undefined,
290 }
291 }
292
293 extractInput(request: OpenAIVideoAiLog['request']) {
294 return {
295 prompt: request.prompt || '',
296 image: request.input_reference,
297 }
298 }
299
300 /**
301 * 创建 Sora 角色
302 */
303 async createCharacter(request: UserSoraCharacterCreateRequestDto): Promise<SoraCharacterCallbackDto> {
304 const { userId, userType, prompt, videoUrl, taskId, timestamps } = request
305
306 let url: string | undefined
307 let soraTaskId: string | undefined
308
309 if (taskId) {
310 const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType)
311 if (!aiLog || aiLog.channel !== AiLogChannel.OpenAI || !aiLog.taskId) {
312 throw new AppException(ResponseCode.InvalidAiTaskId)
313 }
314 soraTaskId = aiLog.taskId
315 }
316 else if (videoUrl) {
317 url = videoUrl
318 }
319 else {
320 throw new AppException(ResponseCode.InvalidAiTaskId)
321 }
322
323 const result = await this.openaiLibService.createCharacter({
324 model: 'sora-2-character',
325 url,
326 taskId: soraTaskId,
327 timestamps,
328 prompt,
329 })
330 this.logger.debug({ result }, 'Create Sora character')
331
332 return {
333 id: result.id,
334 object: 'character',
335 model: 'sora-2-character',
336 status: result.status,
337 username: result.username,
338 created_at: result.created_at,
339 completed_at: result.completed_at,
340 error: result.error,
341 }
342 }
343
344 /**
345 * 查询 Sora 角色状态
346 */
347 async getCharacter(userId: string, userType: UserType, characterId: string): Promise<SoraCharacterCallbackDto> {
348 const result = await this.openaiLibService.getCharacter(characterId)
349
350 return {
351 id: result.id,
352 object: 'character',
353 model: 'sora-2-character',
354 status: result.status,
355 username: result.username,
356 avatar_url: result.avatar_url,
357 created_at: result.created_at,
358 completed_at: result.completed_at,
359 error: result.error,
360 }
361 }
362 }
363
363 lines TYPESCRIPT