返回 AiToEarn
volcengine.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / video / volcengine / volcengine.service.ts
1 import type { VolcengineVideoAiLog } from '../video-ai-log.interface'
2 import type { UserVideoGenerationRequestDto } from '../video.dto'
3 import { BadRequestException, Injectable, Logger } 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 { config } from '../../../../config'
9 import { AiAvailabilityService } from '../../../ai-availability/ai-availability.service'
10 import {
11 AudioRole,
12 Content,
13 ContentType,
14 CreateVideoGenerationTaskRequest,
15 CreateVideoGenerationTaskResponse,
16 GetVideoGenerationTaskResponse,
17 ImageRole,
18 parseModelTextCommand,
19 ToolType,
20 VideoRole,
21 VolcengineService as VolcengineLibService,
22 TaskStatus as VolcTaskStatus,
23 } from '../../libs/volcengine'
24 import { UserVolcengineGenerationRequestDto, VolcengineCallbackDto } from './volcengine.dto'
25
26 @Injectable()
27 export class VolcengineVideoService {
28 private readonly logger = new Logger(VolcengineVideoService.name)
29
30 constructor(
31 private readonly volcengineLibService: VolcengineLibService,
32 private readonly aiLogRepo: AiLogRepository,
33 private readonly assetsService: AssetsService,
34 private readonly storageProvider: StorageProvider,
35 private readonly aiAvailability: AiAvailabilityService,
36 ) {}
37
38 private async toAccessibleUrl(url: string | undefined): Promise<string | undefined> {
39 if (!url) {
40 return undefined
41 }
42 const parsed = this.storageProvider.parsePathFromUrl(url)
43 if (parsed.startsWith('http')) {
44 return url
45 }
46 return this.storageProvider.toPresignedUrl(url)
47 }
48
49 async createFromRequest(request: UserVideoGenerationRequestDto): Promise<{ id: string }> {
50 const content: Content[] = []
51 const legacyFrameImage = !Array.isArray(request.image) ? request.image : undefined
52 const additionalReferenceImages = [...(Array.isArray(request.image) ? request.image : []), ...(request.images || [])]
53 const referenceVideos = [...(request.video_url ? [request.video_url] : []), ...(request.videos || [])]
54 const referenceAudios = request.audios || []
55 const hasExplicitReferenceMedia = additionalReferenceImages.length > 0 || referenceVideos.length > 0 || referenceAudios.length > 0
56 const usesLegacyFrameInput = !!legacyFrameImage && (!hasExplicitReferenceMedia || !!request.image_tail)
57 const referenceImages = [
58 ...additionalReferenceImages,
59 ...(!usesLegacyFrameInput && legacyFrameImage ? [legacyFrameImage] : []),
60 ]
61
62 if (usesLegacyFrameInput) {
63 content.push({
64 type: ContentType.ImageUrl,
65 image_url: { url: await this.toAccessibleUrl(legacyFrameImage) || legacyFrameImage },
66 role: ImageRole.FirstFrame,
67 })
68 }
69
70 if (request.image_tail) {
71 if (!legacyFrameImage) {
72 throw new BadRequestException('image_tail requires a single first frame image')
73 }
74 content.push({
75 type: ContentType.ImageUrl,
76 image_url: { url: await this.toAccessibleUrl(request.image_tail) || request.image_tail },
77 role: ImageRole.LastFrame,
78 })
79 }
80
81 if (!request.image_tail) {
82 for (const imageUrl of referenceImages) {
83 content.push({
84 type: ContentType.ImageUrl,
85 image_url: { url: await this.toAccessibleUrl(imageUrl) || imageUrl },
86 role: usesLegacyFrameInput && imageUrl === legacyFrameImage ? ImageRole.FirstFrame : ImageRole.ReferenceImage,
87 })
88 }
89 }
90
91 for (const referenceVideo of referenceVideos) {
92 content.push({
93 type: ContentType.VideoUrl,
94 video_url: { url: await this.toAccessibleUrl(referenceVideo) || referenceVideo },
95 role: VideoRole.ReferenceVideo,
96 })
97 }
98
99 for (const referenceAudio of referenceAudios) {
100 content.push({
101 type: ContentType.AudioUrl,
102 audio_url: { url: await this.toAccessibleUrl(referenceAudio) || referenceAudio },
103 role: AudioRole.ReferenceAudio,
104 })
105 }
106
107 content.push({
108 type: ContentType.Text,
109 text: request.prompt,
110 })
111
112 const result = await this.create({
113 userId: request.userId,
114 userType: request.userType,
115 model: request.model,
116 content,
117 duration: request.duration,
118 resolution: request.resolution || request.size,
119 ratio: request.ratio || request.metadata?.['aspectRatio'] as string | undefined,
120 seed: request.seed,
121 watermark: request.watermark,
122 tools: request.tools?.map(tool => ({ type: tool.type === 'web_search' ? ToolType.WebSearch : tool.type })),
123 })
124
125 return { id: result.id }
126 }
127
128 private buildSafetyIdentifier(userId: string, userType: UserType): string {
129 return `${userType}:${userId}`
130 }
131
132 private getFailureMessage(status: VolcTaskStatus, callbackData: VolcengineCallbackDto): string | undefined {
133 if (status === VolcTaskStatus.Failed) {
134 return callbackData.error?.message || 'Volcengine task failed'
135 }
136
137 if (status === VolcTaskStatus.Expired) {
138 return callbackData.error?.message || 'Volcengine task expired'
139 }
140
141 return undefined
142 }
143
144 private validateContent(content: Content[]) {
145 if (content.length === 0) {
146 throw new BadRequestException('content is required')
147 }
148
149 const imageContents = content.filter((item): item is Extract<Content, { type: ContentType.ImageUrl }> => item.type === ContentType.ImageUrl)
150 const videoContents = content.filter((item): item is Extract<Content, { type: ContentType.VideoUrl }> => item.type === ContentType.VideoUrl)
151 const audioContents = content.filter((item): item is Extract<Content, { type: ContentType.AudioUrl }> => item.type === ContentType.AudioUrl)
152
153 const firstFrameImages = imageContents.filter(item => !item.role || item.role === ImageRole.FirstFrame)
154 const lastFrameImages = imageContents.filter(item => item.role === ImageRole.LastFrame)
155 const referenceImages = imageContents.filter(item => item.role === ImageRole.ReferenceImage)
156 const referenceVideos = videoContents.filter(item => item.role === VideoRole.ReferenceVideo)
157 const referenceAudios = audioContents.filter(item => item.role === AudioRole.ReferenceAudio)
158
159 const hasFrameScene = firstFrameImages.length > 0 || lastFrameImages.length > 0
160 const hasReferenceScene = referenceImages.length > 0 || referenceVideos.length > 0 || referenceAudios.length > 0
161
162 if (firstFrameImages.length > 1) {
163 throw new BadRequestException('Only one first frame image is allowed')
164 }
165
166 if (lastFrameImages.length > 1) {
167 throw new BadRequestException('Only one last frame image is allowed')
168 }
169
170 if (lastFrameImages.length > 0 && firstFrameImages.length === 0) {
171 throw new BadRequestException('last_frame requires first_frame')
172 }
173
174 if (hasFrameScene && hasReferenceScene) {
175 throw new BadRequestException('first_frame/last_frame and reference media cannot be mixed')
176 }
177
178 if (referenceAudios.length > 0 && referenceImages.length === 0 && referenceVideos.length === 0) {
179 throw new BadRequestException('reference_audio requires at least one reference image or reference video')
180 }
181 }
182
183 private normalizeRequest(request: UserVolcengineGenerationRequestDto) {
184 let prompt = ''
185 let inlineResolution: string | undefined
186 let inlineRatio: string | undefined
187 let inlineDuration: number | undefined
188 let inlineSeed: number | undefined
189 let inlineWatermark: boolean | undefined
190
191 const normalizedContent = request.content.map((item) => {
192 if (item.type !== ContentType.Text) {
193 return item
194 }
195
196 const parsed = parseModelTextCommand(item.text)
197 if (!prompt && parsed.prompt) {
198 prompt = parsed.prompt
199 }
200 inlineResolution = inlineResolution ?? parsed.params.resolution
201 inlineRatio = inlineRatio ?? parsed.params.ratio
202 inlineDuration = inlineDuration ?? parsed.params.duration
203 inlineSeed = inlineSeed ?? parsed.params.seed
204 inlineWatermark = inlineWatermark ?? parsed.params.watermark
205
206 return {
207 ...item,
208 text: parsed.prompt,
209 }
210 })
211
212 this.validateContent(normalizedContent)
213 if (!prompt.trim()) {
214 throw new BadRequestException('text prompt is required')
215 }
216
217 const aiLogRequest = {
218 model: request.model,
219 content: normalizedContent,
220 return_last_frame: request.return_last_frame,
221 resolution: request.resolution ?? inlineResolution,
222 ratio: request.ratio ?? inlineRatio,
223 duration: request.duration ?? inlineDuration,
224 seed: request.seed ?? inlineSeed,
225 watermark: request.watermark ?? inlineWatermark,
226 tools: request.tools,
227 }
228
229 const requestBody: CreateVideoGenerationTaskRequest = {
230 ...aiLogRequest,
231 callback_url: config.ai.volcengine?.callbackUrl,
232 safety_identifier: this.buildSafetyIdentifier(request.userId, request.userType),
233 }
234
235 return {
236 prompt,
237 requestBody,
238 aiLogRequest,
239 }
240 }
241
242 /**
243 * Volcengine视频生成
244 */
245 async create(request: UserVolcengineGenerationRequestDto) {
246 const { userId, userType, model } = request
247 const normalized = this.normalizeRequest(request)
248
249 const startedAt = new Date()
250 const result = await this.aiAvailability.executeAsync(
251 { provider: 'volcengine', operation: 'videoGeneration', model },
252 () => this.volcengineLibService.createVideoGenerationTask(normalized.requestBody),
253 r => r.id,
254 )
255
256 const aiLog = await this.aiLogRepo.create({
257 userId,
258 userType,
259 taskId: result.id,
260 model,
261 channel: AiLogChannel.Volcengine,
262 startedAt,
263 type: AiLogType.Video,
264 request: normalized.aiLogRequest,
265 status: AiLogStatus.Generating,
266 })
267
268 return {
269 ...result,
270 id: aiLog.id,
271 } as CreateVideoGenerationTaskResponse
272 }
273
274 /**
275 * Volcengine回调处理
276 */
277 async callback(callbackData: GetVideoGenerationTaskResponse) {
278 const { id, status, updated_at, content } = callbackData
279
280 const aiLog = await this.aiLogRepo.getByTaskId(id)
281 if (!aiLog || aiLog.channel !== AiLogChannel.Volcengine) {
282 throw new AppException(ResponseCode.InvalidAiTaskId)
283 }
284 const volcengineAiLog = aiLog as VolcengineVideoAiLog
285
286 if (volcengineAiLog.status !== AiLogStatus.Generating) {
287 return
288 }
289
290 if (
291 status !== VolcTaskStatus.Succeeded
292 && status !== VolcTaskStatus.Failed
293 && status !== VolcTaskStatus.Expired
294 ) {
295 return
296 }
297
298 let aiLogStatus: AiLogStatus
299 switch (status) {
300 case VolcTaskStatus.Succeeded:
301 aiLogStatus = AiLogStatus.Success
302 break
303 case VolcTaskStatus.Failed:
304 case VolcTaskStatus.Expired:
305 aiLogStatus = AiLogStatus.Failed
306 break
307 default:
308 aiLogStatus = AiLogStatus.Generating
309 break
310 }
311
312 if (content) {
313 if (content.last_frame_url) {
314 const result = await this.assetsService.uploadFromUrl(volcengineAiLog.userId, {
315 url: content.last_frame_url,
316 type: AssetType.AiImage,
317 }, `${volcengineAiLog.model}`)
318 content.last_frame_url = result.asset.path
319 }
320
321 const result = await this.assetsService.uploadFromUrl(volcengineAiLog.userId, {
322 url: content.video_url,
323 type: AssetType.AiVideo,
324 }, `${volcengineAiLog.model}`)
325 content.video_url = result.asset.path
326 }
327
328 const duration = (updated_at * 1000) - volcengineAiLog.startedAt.getTime()
329 const failureMessage = this.getFailureMessage(status, callbackData)
330
331 const updatedAiLog = await this.aiLogRepo.updateByIdAndStatus(volcengineAiLog.id, AiLogStatus.Generating, {
332 $set: {
333 status: aiLogStatus,
334 response: callbackData,
335 duration,
336 errorMessage: failureMessage,
337 },
338 })
339
340 if (!updatedAiLog) {
341 return
342 }
343
344 if (aiLogStatus === AiLogStatus.Success || aiLogStatus === AiLogStatus.Failed) {
345 await this.aiAvailability.recordAsyncComplete(
346 id,
347 { provider: 'volcengine', operation: 'videoGeneration', model: volcengineAiLog.model },
348 {
349 success: aiLogStatus === AiLogStatus.Success,
350 latencyMs: duration,
351 errorMessage: aiLogStatus === AiLogStatus.Failed ? failureMessage : undefined,
352 },
353 )
354 }
355 }
356
357 /**
358 * 查询Volcengine任务结果
359 */
360 getTaskResult(result: GetVideoGenerationTaskResponse | VolcengineCallbackDto) {
361 const status = {
362 [VolcTaskStatus.Succeeded]: TaskStatus.Success,
363 [VolcTaskStatus.Queued]: TaskStatus.Submitted,
364 [VolcTaskStatus.Running]: TaskStatus.InProgress,
365 [VolcTaskStatus.Failed]: TaskStatus.Failure,
366 [VolcTaskStatus.Cancelled]: TaskStatus.Failure,
367 [VolcTaskStatus.Expired]: TaskStatus.Failure,
368 }[result.status]
369
370 return {
371 status,
372 videoUrl: result.content?.video_url ? FileUtil.buildUrl(result.content.video_url) : undefined,
373 error: result.error ? { message: result.error.message } : undefined,
374 }
375 }
376
377 extractInput(request: VolcengineVideoAiLog['request']) {
378 const content = request.content as Content[]
379 let prompt = ''
380 let image: string | string[] | undefined
381
382 const images: string[] = []
383 const videos: string[] = []
384 const audios: string[] = []
385
386 if (content && Array.isArray(content)) {
387 const textContent = content.find(c => c.type === ContentType.Text)
388 if (textContent && textContent.text) {
389 const parsed = parseModelTextCommand(textContent.text)
390 prompt = parsed.prompt
391 }
392
393 content.forEach((item) => {
394 switch (item.type) {
395 case ContentType.ImageUrl:
396 if (item.image_url?.url) {
397 images.push(item.image_url.url)
398 }
399 break
400 case ContentType.VideoUrl:
401 if (item.video_url?.url) {
402 videos.push(item.video_url.url)
403 }
404 break
405 case ContentType.AudioUrl:
406 if (item.audio_url?.url) {
407 audios.push(item.audio_url.url)
408 }
409 break
410 default:
411 break
412 }
413 })
414
415 const firstFrameImage = content.find((c): c is Extract<Content, { type: ContentType.ImageUrl }> => (
416 c.type === ContentType.ImageUrl && (!c.role || c.role === ImageRole.FirstFrame)
417 ))
418 if (firstFrameImage?.image_url?.url) {
419 image = firstFrameImage.image_url.url
420 }
421 else if (images.length === 1) {
422 image = images[0]
423 }
424 else if (images.length > 1) {
425 image = images
426 }
427 }
428
429 return {
430 prompt,
431 image,
432 images: images.length > 0 ? images : undefined,
433 videoUrl: videos[0],
434 videos: videos.length > 0 ? videos : undefined,
435 audios: audios.length > 0 ? audios : undefined,
436 duration: request.duration,
437 aspectRatio: request.ratio,
438 resolution: request.resolution,
439 watermark: request.watermark,
440 }
441 }
442
443 async getTask(userId: string, userType: UserType, taskId: string) {
444 const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType)
445
446 if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Volcengine) {
447 throw new AppException(ResponseCode.InvalidAiTaskId)
448 }
449 const volcengineAiLog = aiLog as VolcengineVideoAiLog
450
451 if (volcengineAiLog.status === AiLogStatus.Generating) {
452 const result = await this.volcengineLibService.getVideoGenerationTask(volcengineAiLog.taskId!)
453 if (
454 result.status === VolcTaskStatus.Succeeded
455 || result.status === VolcTaskStatus.Failed
456 || result.status === VolcTaskStatus.Expired
457 ) {
458 await this.callback(result)
459 }
460 return result
461 }
462 return volcengineAiLog.response!
463 }
464 }
465
465 lines TYPESCRIPT