返回 AiToEarn
draft-generation.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / draft-generation / draft-generation.service.ts
1 import type { DraftGenerationData, DraftGenerationQueueInfo } from '@yikart/aitoearn-queue'
2 import { HumanMessage } from '@langchain/core/messages'
3 import { ChatGoogleGenerativeAI } from '@langchain/google-genai'
4 import { BadRequestException, Injectable, Logger } from '@nestjs/common'
5 import { QueueService } from '@yikart/aitoearn-queue'
6 import { AssetsService, VideoMetadataService } from '@yikart/assets'
7 import { AccountType, AppException, FileUtil, getErrorMessage, poll, ResponseCode, retry, UserType } from '@yikart/common'
8 import {
9 AiLogChannel,
10 AiLogRepository,
11 AiLogStatus,
12 AiLogType,
13 AssetType,
14 MaterialGroupRepository,
15 MaterialRepository,
16 MaterialSource,
17 MaterialStatus,
18 MaterialType,
19 MediaRepository,
20 MediaType,
21 } from '@yikart/mongodb'
22 import { z } from 'zod'
23 import { TaskStatus } from '../../common'
24 import { config } from '../../config'
25 import { AiAvailabilityService } from '../ai-availability'
26 import { ImageService } from '../ai/image/image.service'
27 import { VideoService } from '../ai/video/video.service'
28 import { DraftGenerationMemoryService } from './draft-generation-memory.service'
29 import { DraftGenerationPlannerService, ImageTextDraftPlanResult, VideoDraftPlanResult } from './draft-generation-planner.service'
30 import { getCompatibleAccountTypes } from './draft-generation-platforms'
31 import {
32 CreateDraftFromVideoUrlDto,
33 CreateDraftGenerationV2Dto,
34 CreateImageTextDraftDto,
35 DraftGenerationMemoryContentType,
36 DraftType,
37 ImageTextDraftType,
38 ListDraftGenerationTasksDto,
39 QueryDraftGenerationTasksDto,
40 } from './draft-generation.dto'
41 import { DraftGenerationPricingVo } from './draft-generation.vo'
42
43 export class DraftGenerationError extends Error {
44 constructor(
45 message: string,
46 cause?: unknown,
47 ) {
48 super(message, { cause })
49 this.name = 'DraftGenerationError'
50 }
51 }
52
53 const VideoDraftMetadataResultSchema = z.object({
54 title: z.string().max(200).describe('TikTok video title'),
55 description: z.string().max(2200).describe('TikTok video description'),
56 topics: z.array(z.string()).max(5).describe('Hashtag topics without # prefix'),
57 })
58
59 interface ImageGenerationPrompt {
60 prompt: string
61 promptIndex: number
62 }
63
64 interface ImageGenerationErrorDetail {
65 promptIndex: number
66 errorMessage: string
67 }
68
69 type ImageGenerationProgressHandler = (imageUrl: string, imageGenerationErrors: ImageGenerationErrorDetail[]) => Promise<void>
70
71 type DraftGenerationTaskWithQueue<T extends { id: string, status: AiLogStatus }> = T & {
72 queue?: DraftGenerationQueueInfo
73 }
74
75 enum ImageExecutionReferenceHandling {
76 None = 'none',
77 Reference = 'reference',
78 Edit = 'edit',
79 Ignored = 'ignored',
80 }
81
82 const OPENAI_IMAGE_DEFAULT_ASPECT_RATIO = '2:3'
83 const OPENAI_IMAGE_DEFAULT_RESOLUTION = '1K'
84
85 function getOpenAIImageSizeProfile(imageSize: string): { maxEdge: number, maxPixels: number, squareSize?: string } {
86 switch (imageSize) {
87 case '1K':
88 return { maxEdge: 1536, maxPixels: 1536 * 1024, squareSize: '1024x1024' }
89 case '2K':
90 return { maxEdge: 2560, maxPixels: 2560 * 1440 }
91 case '4K':
92 return { maxEdge: 3840, maxPixels: 3840 * 2160 }
93 default:
94 throw new BadRequestException('imageSize must be one of 1K, 2K, or 4K')
95 }
96 }
97 const OPENAI_IMAGE_SIZE_MULTIPLE = 16
98 const OPENAI_IMAGE_MIN_ASPECT_RATIO = 1 / 3
99 const OPENAI_IMAGE_MAX_ASPECT_RATIO = 3
100
101 function getGreatestCommonDivisor(left: number, right: number): number {
102 let a = Math.abs(left)
103 let b = Math.abs(right)
104
105 while (b !== 0) {
106 const next = a % b
107 a = b
108 b = next
109 }
110
111 return a
112 }
113
114 interface ResolvedImageExecution {
115 referenceHandling: ImageExecutionReferenceHandling
116 resolvedSize?: string
117 mode: 'gemini' | 'openai-generation' | 'openai-edit'
118 channel: AiLogChannel
119 }
120
121 /** V2 视频草稿 AiLog.response */
122 interface V2DraftResponse {
123 materialId?: string
124 mediaId?: string
125 title?: string
126 description?: string
127 topics?: string[]
128 videoUrl?: string
129 coverUrl?: string
130 plan?: VideoDraftPlanResult
131 }
132
133 /** 图文草稿 AiLog.response */
134 interface ImageTextDraftResponse {
135 materialId?: string
136 mediaIds?: string[]
137 title?: string
138 description?: string
139 topics?: string[]
140 coverUrl?: string
141 imageUrls?: string[]
142 requestedImageCount?: number
143 generatedImageCount?: number
144 imageGenerationErrors?: ImageGenerationErrorDetail[]
145 plan?: ImageTextDraftPlanResult
146 }
147
148 @Injectable()
149 export class DraftGenerationService {
150 private readonly logger = new Logger(DraftGenerationService.name)
151
152 constructor(
153 private readonly materialGroupRepository: MaterialGroupRepository,
154 private readonly materialRepository: MaterialRepository,
155 private readonly aiLogRepository: AiLogRepository,
156 private readonly queueService: QueueService,
157 private readonly videoService: VideoService,
158 private readonly assetsService: AssetsService,
159 private readonly videoMetadataService: VideoMetadataService,
160 private readonly aiAvailability: AiAvailabilityService,
161 private readonly imageService: ImageService,
162 private readonly mediaRepository: MediaRepository,
163 private readonly draftGenerationPlannerService: DraftGenerationPlannerService,
164 private readonly draftGenerationMemoryService: DraftGenerationMemoryService,
165 ) { }
166
167 async getTask(taskId: string, userId: string, userType: UserType) {
168 const aiLog = await this.aiLogRepository.getByIdAndUserId(taskId, userId, userType)
169 if (!aiLog) {
170 throw new AppException(ResponseCode.AiLogNotFound)
171 }
172 return await this.attachQueueInfo(aiLog)
173 }
174
175 async listTasks(dto: QueryDraftGenerationTasksDto, userId: string, userType: UserType) {
176 const tasks = await this.aiLogRepository.listByIdsAndUserId(dto.taskIds, userId, userType)
177 return await this.attachQueueInfoToTasks(tasks)
178 }
179
180 async listTasksWithPagination(dto: ListDraftGenerationTasksDto, userId: string, userType: UserType) {
181 const [tasks, total] = await this.aiLogRepository.listWithPagination({
182 ...dto,
183 userId,
184 userType,
185 type: AiLogType.DraftGeneration,
186 })
187 return [await this.attachQueueInfoToTasks(tasks), total] as const
188 }
189
190 async getStats(userId: string, userType: UserType) {
191 const generatingCount = await this.aiLogRepository.countByUserIdAndStatus(
192 userId,
193 userType,
194 AiLogType.DraftGeneration,
195 AiLogStatus.Generating,
196 )
197 return { generatingCount }
198 }
199
200 private async addDraftGenerationJob(data: DraftGenerationData): Promise<void> {
201 const options = data.queuePriority == null ? undefined : { priority: data.queuePriority }
202 if (
203 data.queuePriority != null
204 && data.queuePriority >= config.ai.draftGeneration.queue.lowPriorityMinPriority
205 ) {
206 await this.queueService.addLowPriorityDraftGenerationJob(data, options)
207 return
208 }
209
210 await this.queueService.addDraftGenerationJob(data, options)
211 }
212
213 private async attachQueueInfo<T extends { id: string, status: AiLogStatus }>(task: T): Promise<DraftGenerationTaskWithQueue<T>> {
214 if (task.status !== AiLogStatus.Generating) {
215 return task
216 }
217
218 const queue = await this.queueService.getDraftGenerationQueueInfo(task.id)
219 if (!queue) {
220 return task
221 }
222
223 return { ...task, queue }
224 }
225
226 private async attachQueueInfoToTasks<T extends { id: string, status: AiLogStatus }>(tasks: T[]): Promise<Array<DraftGenerationTaskWithQueue<T>>> {
227 return await Promise.all(tasks.map(task => this.attachQueueInfo(task)))
228 }
229
230 private getVideoDraftModelConfig(model: string) {
231 const modelConfig = config.ai.models.video.generation.find(m => m.name === model)
232 if (!modelConfig) {
233 throw new AppException(ResponseCode.InvalidModel)
234 }
235
236 return modelConfig
237 }
238
239 private resolveVideoReferenceMode(
240 modelConfig: ReturnType<DraftGenerationService['getVideoDraftModelConfig']>,
241 videoUrls?: string[],
242 audioUrls?: string[],
243 ): 'multi-ref' | 'video2video' | undefined {
244 const hasVideoReference = (videoUrls?.length ?? 0) > 0
245 const hasAudioReference = (audioUrls?.length ?? 0) > 0
246 if (!hasVideoReference && !hasAudioReference) {
247 return undefined
248 }
249
250 if (modelConfig.modes.includes('multi-ref')) {
251 return 'multi-ref'
252 }
253
254 if (hasVideoReference && !hasAudioReference && modelConfig.modes.includes('video2video')) {
255 return 'video2video'
256 }
257
258 throw new AppException(ResponseCode.InvalidModel)
259 }
260
261 private getImageTextDraftModelConfig(model: string) {
262 const modelConfig = config.ai.draftGeneration.imageModels.find(m => m.model === model)
263 if (!modelConfig) {
264 throw new AppException(ResponseCode.InvalidModel)
265 }
266
267 return modelConfig
268 }
269
270 private resolveOpenAIImageSize(aspectRatio?: string, imageSize = OPENAI_IMAGE_DEFAULT_RESOLUTION): string {
271 const sizeProfile = getOpenAIImageSizeProfile(imageSize)
272 const parts = (aspectRatio ?? OPENAI_IMAGE_DEFAULT_ASPECT_RATIO).split(':')
273 if (parts.length !== 2) {
274 throw new BadRequestException('image aspectRatio must use WIDTH:HEIGHT')
275 }
276
277 const widthRatio = Number(parts[0])
278 const heightRatio = Number(parts[1])
279 if (!Number.isInteger(widthRatio) || !Number.isInteger(heightRatio) || widthRatio <= 0 || heightRatio <= 0) {
280 throw new BadRequestException('image aspectRatio must use positive integer WIDTH:HEIGHT')
281 }
282
283 const ratio = widthRatio / heightRatio
284 if (ratio < OPENAI_IMAGE_MIN_ASPECT_RATIO || ratio > OPENAI_IMAGE_MAX_ASPECT_RATIO) {
285 throw new BadRequestException('image aspectRatio must be between 1:3 and 3:1')
286 }
287
288 if (widthRatio === heightRatio) {
289 return sizeProfile.squareSize ?? this.resolveScaledOpenAIImageSize(widthRatio, heightRatio, sizeProfile)
290 }
291
292 return this.resolveScaledOpenAIImageSize(widthRatio, heightRatio, sizeProfile)
293 }
294
295 private resolveScaledOpenAIImageSize(
296 widthRatio: number,
297 heightRatio: number,
298 sizeProfile: { maxEdge: number, maxPixels: number },
299 ): string {
300 const divisor = getGreatestCommonDivisor(widthRatio, heightRatio)
301 const reducedWidth = widthRatio / divisor
302 const reducedHeight = heightRatio / divisor
303 const maxScaleByEdge = Math.floor(sizeProfile.maxEdge / Math.max(reducedWidth, reducedHeight))
304 const maxScaleByPixels = Math.floor(Math.sqrt(sizeProfile.maxPixels / (reducedWidth * reducedHeight)))
305 const maxScale = Math.min(maxScaleByEdge, maxScaleByPixels)
306 const scale = Math.floor(maxScale / OPENAI_IMAGE_SIZE_MULTIPLE) * OPENAI_IMAGE_SIZE_MULTIPLE
307
308 if (scale < OPENAI_IMAGE_SIZE_MULTIPLE) {
309 throw new BadRequestException('image aspectRatio cannot be converted to a supported size')
310 }
311
312 return `${reducedWidth * scale}x${reducedHeight * scale}`
313 }
314
315 private resolveImageExecution(
316 imageModel: string,
317 referenceImageUrls: string[],
318 aspectRatio?: string,
319 imageSize?: string,
320 ): ResolvedImageExecution {
321 this.getImageTextDraftModelConfig(imageModel)
322
323 const geminiImageModel = config.ai.models.chat.find(model =>
324 model.name === imageModel
325 && model.channel === AiLogChannel.Gemini
326 && model.outputModalities.includes('image'),
327 )
328 const generationModel = config.ai.models.image.generation.find(model => model.name === imageModel)
329 const editModel = config.ai.models.image.edit.find(model => model.name === imageModel)
330
331 if (geminiImageModel && (generationModel || editModel)) {
332 throw new AppException(ResponseCode.InvalidModel)
333 }
334
335 if (geminiImageModel) {
336 return {
337 mode: 'gemini',
338 channel: geminiImageModel.channel,
339 referenceHandling: referenceImageUrls.length > 0 ? ImageExecutionReferenceHandling.Reference : ImageExecutionReferenceHandling.None,
340 }
341 }
342
343 const resolvedSize = this.resolveOpenAIImageSize(aspectRatio, imageSize)
344
345 if (referenceImageUrls.length > 0 && editModel) {
346 return {
347 mode: 'openai-edit',
348 channel: AiLogChannel.NewApi,
349 referenceHandling: ImageExecutionReferenceHandling.Edit,
350 resolvedSize,
351 }
352 }
353
354 if (generationModel) {
355 return {
356 mode: 'openai-generation',
357 channel: AiLogChannel.NewApi,
358 referenceHandling: referenceImageUrls.length > 0 ? ImageExecutionReferenceHandling.Ignored : ImageExecutionReferenceHandling.None,
359 resolvedSize,
360 }
361 }
362
363 throw new AppException(ResponseCode.InvalidModel)
364 }
365
366 // ==================== V2: 固定管线(无 Agent) ====================
367
368 /**
369 * V2: 创建草稿生成任务(投递队列时标记 version=v2)
370 * 支持选择 modelType、duration、aspectRatio
371 */
372 async createDraftsV2(userId: string, userType: UserType, dto: CreateDraftGenerationV2Dto): Promise<string[]> {
373 const modelConfig = config.ai.models.video.generation.find(m => m.name === dto.model)
374 if (!modelConfig) {
375 throw new AppException(ResponseCode.InvalidModel)
376 }
377
378 const resolution = dto.resolution ?? modelConfig.defaults?.resolution
379 const resolvedGroupId = await this.resolveDraftGroupId(userId, dto.groupId)
380 const draftType = dto.draftType ?? 'draft'
381 const queuePriority = modelConfig.queuePriority
382 const plannerModel = draftType === 'draft' ? dto.plannerModel ?? config.ai.draftGeneration.planner.defaultModel : undefined
383 if (plannerModel && !config.ai.models.chat.some(model => model.name === plannerModel && model.scenes?.includes('draft-generation'))) {
384 throw new AppException(ResponseCode.InvalidModel)
385 }
386
387 const quantity = dto.quantity ?? 1
388 const aiLogIds: string[] = []
389
390 for (let i = 0; i < quantity; i++) {
391 const aiLog = await this.aiLogRepository.create({
392 userId,
393 userType,
394 type: AiLogType.DraftGeneration,
395 model: dto.model,
396 channel: modelConfig.channel as AiLogChannel,
397 status: AiLogStatus.Generating,
398 startedAt: new Date(),
399 request: {
400 groupId: resolvedGroupId,
401 version: 'v2',
402 model: dto.model,
403 duration: dto.duration,
404 resolution,
405 aspectRatio: dto.aspectRatio,
406 prompt: dto.prompt,
407 captionPrompt: dto.captionPrompt,
408 imageUrls: dto.imageUrls,
409 videoUrls: dto.videoUrls,
410 audioUrls: dto.audioUrls,
411 draftType,
412 plannerModel,
413 queuePriority,
414 },
415 response: {},
416 })
417
418 await this.addDraftGenerationJob({
419 aiLogId: aiLog.id,
420 userId,
421 userType,
422 groupId: resolvedGroupId,
423 version: 'v2',
424 prompt: dto.prompt,
425 captionPrompt: dto.captionPrompt,
426 imageUrls: dto.imageUrls,
427 model: dto.model,
428 duration: dto.duration,
429 resolution,
430 aspectRatio: dto.aspectRatio,
431 videoUrls: dto.videoUrls,
432 audioUrls: dto.audioUrls,
433 draftType,
434 platforms: dto.platforms,
435 plannerModel,
436 disableMemory: dto.disableMemory,
437 queuePriority,
438 })
439
440 aiLogIds.push(aiLog.id)
441 }
442
443 return aiLogIds
444 }
445
446 /**
447 * V2: 固定管线执行草稿内容生成(由 Consumer 调用)
448 *
449 * 流程:
450 * 1. 规划模型生成视频 prompt + 元数据
451 * 2. 调用视频模型生成视频
452 * 3. 截帧生成封面
453 * 4. 保存素材 + 更新 AiLog
454 */
455 async generateContentV2(
456 aiLogId: string,
457 userId: string,
458 userType: UserType,
459 groupId: string,
460 options?: {
461 prompt?: string
462 captionPrompt?: string
463 imageUrls?: string[]
464 model?: string
465 duration?: number
466 resolution?: string
467 aspectRatio?: string
468 videoUrls?: string[]
469 audioUrls?: string[]
470 draftType?: DraftType
471 platforms?: string[]
472 plannerModel?: string
473 disableMemory?: boolean
474 },
475 ): Promise<void> {
476 const startTime = Date.now()
477 const draftType = options?.draftType ?? 'draft'
478
479 const existingAiLog = await this.aiLogRepository.getById(aiLogId)
480 const existing = (existingAiLog?.response ?? {}) as V2DraftResponse
481
482 try {
483 const candidateImageUrls = options?.imageUrls ?? []
484 const model = options?.model ?? 'grok-imagine-video'
485 const duration = options?.duration
486 const aspectRatio = options?.aspectRatio ?? '9:16'
487 const resolution = options?.resolution
488
489 let plan: VideoDraftPlanResult | undefined
490 let videoPrompt = options?.prompt ?? ''
491 if (draftType === 'draft') {
492 if (existing.plan?.videoPrompt) {
493 plan = existing.plan
494 this.logger.log({ aiLogId }, 'V2: Reusing plan from previous attempt')
495 }
496 else {
497 const memoryItems = options?.disableMemory
498 ? []
499 : (await this.draftGenerationMemoryService.getPlannerMemoryContext(userId, DraftGenerationMemoryContentType.Video)).memoryItems
500 const planned = await this.draftGenerationPlannerService.planVideo({
501 userId,
502 contentType: DraftGenerationMemoryContentType.Video,
503 plannerModel: options?.plannerModel,
504 userPrompt: options?.prompt,
505 captionPrompt: options?.captionPrompt,
506 memoryItems,
507 referenceImageUrls: candidateImageUrls,
508 referenceVideoUrls: options?.videoUrls,
509 referenceAudioUrls: options?.audioUrls,
510 platforms: options?.platforms,
511 model,
512 duration,
513 aspectRatio,
514 })
515 plan = planned.plan
516
517 await this.aiLogRepository.updateById(aiLogId, {
518 $set: { 'response.plan': plan, 'request.plannerModel': planned.model },
519 })
520 }
521 videoPrompt = plan.videoPrompt
522 }
523
524 // 视频 + 封面:复用已有结果或重新生成(一起保存,不可分开)
525 let videoUrl: string
526 let coverUrl: string
527 if (existing.videoUrl && existing.coverUrl) {
528 videoUrl = existing.videoUrl
529 coverUrl = existing.coverUrl
530 this.logger.log({ aiLogId }, 'V2: Reusing video+cover from previous attempt')
531 }
532 else {
533 const { videoUrl: generatedVideoUrl } = await this.generateVideo(
534 aiLogId,
535 userId,
536 userType,
537 model,
538 videoPrompt,
539 candidateImageUrls.length > 0 ? candidateImageUrls : undefined,
540 duration,
541 resolution,
542 aspectRatio,
543 options?.videoUrls,
544 options?.audioUrls,
545 )
546
547 const fullVideoUrl = FileUtil.buildUrl(generatedVideoUrl)
548 const thumbnailBuffer = await this.videoMetadataService.extractThumbnailFromUrl(fullVideoUrl, 2)
549 const uploadResult = await this.assetsService.uploadFromBuffer(userId, thumbnailBuffer, {
550 type: AssetType.VideoThumbnail,
551 mimeType: 'image/png',
552 filename: 'thumbnail.png',
553 })
554
555 videoUrl = generatedVideoUrl
556 coverUrl = uploadResult.asset.path
557
558 await this.aiLogRepository.updateById(aiLogId, {
559 $set: { 'response.videoUrl': videoUrl, 'response.coverUrl': coverUrl },
560 })
561 }
562
563 if (draftType === 'video') {
564 const mediaId = existing.mediaId ?? (await this.mediaRepository.create({
565 userId,
566 userType,
567 materialGroupId: groupId,
568 type: MediaType.VIDEO,
569 url: videoUrl,
570 thumbUrl: coverUrl,
571 })).id
572
573 const response: V2DraftResponse = {
574 mediaId,
575 videoUrl,
576 coverUrl,
577 }
578 await this.aiLogRepository.updateById(aiLogId, {
579 $set: {
580 status: AiLogStatus.Success,
581 model,
582 duration: Date.now() - startTime,
583 response,
584 },
585 })
586
587 return
588 }
589
590 const draftPlan = plan!
591
592 // draft 类型:复用已有 materialId 或创建新素材
593 const materialId = existing.materialId ?? (await this.materialRepository.create({
594 userId,
595 userType,
596 groupId,
597 type: MaterialType.VIDEO,
598 source: MaterialSource.PlaceDraft,
599 status: MaterialStatus.SUCCESS,
600 title: draftPlan.title,
601 desc: draftPlan.description,
602 topics: draftPlan.topics,
603 coverUrl,
604 mediaList: [{ url: videoUrl, type: MediaType.VIDEO, thumbUrl: coverUrl }],
605 useCount: 0,
606 autoDeleteMedia: false,
607 model,
608 generationParams: options,
609 accountTypes: (options?.platforms as AccountType[]) ?? getCompatibleAccountTypes({
610 type: 'video',
611 title: draftPlan.title,
612 desc: draftPlan.description,
613 topics: draftPlan.topics,
614 duration,
615 aspectRatio,
616 }),
617 })).id
618
619 const response: V2DraftResponse = {
620 materialId,
621 title: draftPlan.title,
622 description: draftPlan.description,
623 topics: draftPlan.topics,
624 videoUrl,
625 coverUrl,
626 plan: draftPlan,
627 }
628
629 await this.aiLogRepository.updateById(aiLogId, {
630 $set: {
631 status: AiLogStatus.Success,
632 model,
633 duration: Date.now() - startTime,
634 response,
635 },
636 })
637 }
638 catch (error) {
639 this.logger.error(error, `v2 generateContentV2 failed aiLogId=${aiLogId}, userId=${userId}, groupId=${groupId}`)
640 throw new DraftGenerationError(
641 getErrorMessage(error),
642 error,
643 )
644 }
645 }
646
647 /**
648 * V2 辅助方法:使用 Grok 生成视频并轮询结果
649 */
650 private async generateVideo(
651 aiLogId: string,
652 userId: string,
653 userType: UserType,
654 model: string,
655 videoPrompt: string,
656 imageUrls?: string[],
657 duration?: number,
658 resolution?: string,
659 aspectRatio?: string,
660 videoUrls?: string[],
661 audioUrls?: string[],
662 ): Promise<{ videoUrl: string }> {
663 let task: { id: string }
664 try {
665 const modelConfig = this.getVideoDraftModelConfig(model)
666 const mode = this.resolveVideoReferenceMode(modelConfig, videoUrls, audioUrls)
667 task = await this.videoService.userVideoGeneration({
668 userId,
669 userType,
670 model,
671 prompt: videoPrompt,
672 image: imageUrls,
673 video_url: mode === 'video2video' ? videoUrls?.[0] : undefined,
674 videos: mode === 'multi-ref' ? videoUrls : undefined,
675 audios: mode === 'multi-ref' ? audioUrls : undefined,
676 mode,
677 duration,
678 resolution,
679 ratio: aspectRatio,
680 metadata: { aspectRatio, resolution },
681 })
682 }
683 catch (error) {
684 const errorMessage = getErrorMessage(error)
685 await this.aiLogRepository.updateById(aiLogId, {
686 $set: {
687 status: AiLogStatus.Failed,
688 errorMessage,
689 },
690 })
691 throw error
692 }
693
694 this.logger.log({ aiLogId, taskId: task.id, model, duration, aspectRatio }, 'V2: video generation started')
695
696 const url = await poll<string>(
697 async () => {
698 const result = await this.videoService.getVideoTaskStatus({ taskId: task.id, userId, userType })
699 if (result.status === TaskStatus.Success) {
700 return { done: true, data: result.videoUrl }
701 }
702 if (result.status === TaskStatus.Failure) {
703 return { done: true, error: result.error?.message }
704 }
705 return { done: false }
706 },
707 {
708 maxPollingMs: 30 * 60 * 1000,
709 taskName: `Video generation (${model})`,
710 },
711 )
712
713 return { videoUrl: url }
714 }
715
716 // ==================== 图文草稿生成 ====================
717
718 getDraftGenerationPricing(): ReturnType<typeof DraftGenerationPricingVo.create> {
719 const imageModels = config.ai.draftGeneration.imageModels
720
721 return DraftGenerationPricingVo.create({ imageModels, videoModels: config.ai.models.video.generation })
722 }
723
724 /**
725 * 创建图文草稿生成任务(同步阶段)
726 */
727 async createImageTextDrafts(userId: string, userType: UserType, dto: CreateImageTextDraftDto): Promise<string[]> {
728 const resolvedGroupId = await this.resolveDraftGroupId(userId, dto.groupId)
729 const draftType = dto.draftType ?? 'draft'
730 const plannerModel = draftType === 'draft' ? dto.plannerModel ?? config.ai.draftGeneration.planner.defaultModel : undefined
731 if (plannerModel && !config.ai.models.chat.some(model => model.name === plannerModel && model.scenes?.includes('draft-generation'))) {
732 throw new AppException(ResponseCode.InvalidModel)
733 }
734
735 const imageModelConfig = this.getImageTextDraftModelConfig(dto.imageModel)
736 const runtimeImageModel = imageModelConfig.runtimeModel ?? dto.imageModel
737 const queuePriority = imageModelConfig.queuePriority
738 const imageExecution = this.resolveImageExecution(dto.imageModel, dto.imageUrls ?? [], dto.aspectRatio, dto.imageSize)
739 const channel = imageExecution.channel
740
741 const quantity = dto.quantity ?? 1
742 const aiLogIds: string[] = []
743
744 for (let i = 0; i < quantity; i++) {
745 const aiLog = await this.aiLogRepository.create({
746 userId,
747 userType,
748 type: AiLogType.DraftGeneration,
749 model: dto.imageModel,
750 channel,
751 status: AiLogStatus.Generating,
752 startedAt: new Date(),
753 request: {
754 groupId: resolvedGroupId,
755 version: 'v2-image-text',
756 imageModel: dto.imageModel,
757 imageCount: dto.imageCount,
758 imageSize: dto.imageSize,
759 aspectRatio: dto.aspectRatio,
760 prompt: dto.prompt,
761 captionPrompt: dto.captionPrompt,
762 imageUrls: dto.imageUrls,
763 draftType,
764 plannerModel,
765 runtimeImageModel,
766 queuePriority,
767 imageExecution,
768 },
769 response: {},
770 })
771
772 await this.addDraftGenerationJob({
773 aiLogId: aiLog.id,
774 userId,
775 userType,
776 groupId: resolvedGroupId,
777 version: 'v2-image-text',
778 prompt: dto.prompt,
779 captionPrompt: dto.captionPrompt,
780 imageUrls: dto.imageUrls,
781 imageModel: dto.imageModel,
782 imageCount: dto.imageCount ?? 3,
783 imageSize: dto.imageSize,
784 aspectRatio: dto.aspectRatio,
785 imageTextDraftType: draftType,
786 platforms: dto.platforms,
787 plannerModel,
788 disableMemory: dto.disableMemory,
789 queuePriority,
790 })
791
792 aiLogIds.push(aiLog.id)
793 }
794
795 return aiLogIds
796 }
797
798 /**
799 * 图文草稿内容生成(异步阶段,由 Consumer 调用)
800 *
801 * 流程:
802 * 1. 规划模型生成 title / description / topics / imagePrompts
803 * 2. 根据 imageModel 批量生成图片(Gemini 或 GPT Image)
804 * 3. 保存素材(类型 ARTICLE)+ 更新 AiLog
805 */
806 async generateContentImageText(
807 aiLogId: string,
808 userId: string,
809 userType: UserType,
810 groupId: string,
811 options: {
812 prompt: string
813 captionPrompt?: string
814 imageUrls?: string[]
815 imageModel: string
816 imageCount: number
817 imageSize?: string
818 aspectRatio?: string
819 draftType?: ImageTextDraftType
820 platforms?: string[]
821 plannerModel?: string
822 disableMemory?: boolean
823 },
824 ): Promise<void> {
825 const existingAiLog = await this.aiLogRepository.getById(aiLogId)
826 const existing = (existingAiLog?.response ?? {}) as ImageTextDraftResponse
827 const startTime = Date.now()
828 const draftType = options.draftType ?? 'draft'
829 const targetImageCount = options.imageCount
830
831 try {
832 const referenceImageUrls = options.imageUrls ?? []
833 const imageExecution = this.resolveImageExecution(options.imageModel, referenceImageUrls, options.aspectRatio, options.imageSize)
834 this.logger.log(
835 {
836 aiLogId,
837 imageModel: options.imageModel,
838 imageCount: options.imageCount,
839 aspectRatio: options.aspectRatio,
840 refImageCount: referenceImageUrls.length,
841 draftType,
842 referenceHandling: imageExecution.referenceHandling,
843 resolvedSize: imageExecution.resolvedSize,
844 },
845 'ImageText: Starting generation',
846 )
847
848 let plan: ImageTextDraftPlanResult | undefined
849 let imagePromptTasks: ImageGenerationPrompt[]
850 if (draftType === 'image') {
851 imagePromptTasks = Array.from({ length: targetImageCount }, (_, promptIndex) => ({
852 prompt: options.prompt,
853 promptIndex,
854 }))
855 }
856 else {
857 const existingPlan = existing.plan
858 if (existingPlan && existingPlan.imagePrompts.length >= targetImageCount) {
859 plan = existingPlan
860 this.logger.log({ aiLogId }, 'ImageText: Reusing plan from previous attempt')
861 }
862 else {
863 const memoryItems = options.disableMemory
864 ? []
865 : (await this.draftGenerationMemoryService.getPlannerMemoryContext(userId, DraftGenerationMemoryContentType.ImageText)).memoryItems
866 const planned = await this.draftGenerationPlannerService.planImageText({
867 userId,
868 contentType: DraftGenerationMemoryContentType.ImageText,
869 plannerModel: options.plannerModel,
870 userPrompt: options.prompt,
871 captionPrompt: options.captionPrompt,
872 memoryItems,
873 referenceImageUrls,
874 platforms: options.platforms,
875 imageModel: options.imageModel,
876 imageCount: targetImageCount,
877 imageSize: options.imageSize,
878 aspectRatio: options.aspectRatio,
879 })
880 plan = planned.plan
881
882 await this.aiLogRepository.updateById(aiLogId, {
883 $set: { 'response.plan': plan, 'request.plannerModel': planned.model },
884 })
885
886 this.logger.log(
887 { aiLogId, title: plan.title, imagePromptsCount: plan.imagePrompts.length, plannerModel: planned.model },
888 'ImageText: Planning completed',
889 )
890 }
891
892 imagePromptTasks = plan.imagePrompts
893 .slice(0, targetImageCount)
894 .map((prompt, promptIndex) => ({ prompt, promptIndex }))
895 }
896 let generatedImageUrls = (existing.imageUrls ?? []).slice(0, targetImageCount)
897 const updateImageProgress = async (
898 imageUrls: string[],
899 imageGenerationErrors: ImageGenerationErrorDetail[] = [],
900 ) => {
901 const response: Record<string, unknown> = {
902 'response.imageUrls': [...imageUrls],
903 'response.requestedImageCount': targetImageCount,
904 'response.generatedImageCount': imageUrls.length,
905 'response.imageGenerationErrors': [...imageGenerationErrors],
906 }
907 if (plan) {
908 response['response.plan'] = plan
909 }
910 await this.aiLogRepository.updateById(aiLogId, {
911 $set: response,
912 })
913 }
914
915 if (generatedImageUrls.length > 0) {
916 this.logger.log({ aiLogId, count: generatedImageUrls.length, targetImageCount }, 'ImageText: Reusing images from previous attempt')
917 }
918
919 if (generatedImageUrls.length < targetImageCount) {
920 const missingPromptTasks = imagePromptTasks.slice(generatedImageUrls.length, targetImageCount)
921 const previousGeneratedImageCount = generatedImageUrls.length
922 const progressImageUrls = [...generatedImageUrls]
923 const { urls, imageGenerationErrors } = await this.generateImages(
924 userId,
925 userType,
926 options.imageModel,
927 missingPromptTasks,
928 imageExecution,
929 referenceImageUrls,
930 options.aspectRatio,
931 options.imageSize,
932 async (imageUrl, imageGenerationErrors) => {
933 if (progressImageUrls.length < targetImageCount) {
934 progressImageUrls.push(imageUrl)
935 }
936 await updateImageProgress(progressImageUrls, [
937 ...(existing.imageGenerationErrors ?? []),
938 ...imageGenerationErrors,
939 ])
940 },
941 )
942 generatedImageUrls = progressImageUrls.length > previousGeneratedImageCount
943 ? progressImageUrls.slice(0, targetImageCount)
944 : [...generatedImageUrls, ...urls].slice(0, targetImageCount)
945
946 await updateImageProgress(generatedImageUrls, imageGenerationErrors)
947
948 this.logger.log(
949 { aiLogId, generatedCount: generatedImageUrls.length, targetImageCount, errorCount: imageGenerationErrors.length },
950 'ImageText: Image generation completed',
951 )
952
953 if (generatedImageUrls.length < targetImageCount) {
954 throw new Error(`ImageText: generated ${generatedImageUrls.length}/${targetImageCount} images`)
955 }
956 }
957
958 if (draftType === 'image') {
959 const mediaIds = (existing.mediaIds ?? []).slice(0, targetImageCount)
960 for (const imageUrl of generatedImageUrls.slice(mediaIds.length)) {
961 const media = await this.mediaRepository.create({
962 userId,
963 userType,
964 materialGroupId: groupId,
965 type: MediaType.IMG,
966 url: imageUrl,
967 })
968 mediaIds.push(media.id)
969 }
970
971 const response: ImageTextDraftResponse = {
972 mediaIds,
973 imageUrls: generatedImageUrls,
974 requestedImageCount: targetImageCount,
975 generatedImageCount: generatedImageUrls.length,
976 }
977 await this.aiLogRepository.updateById(aiLogId, {
978 $set: {
979 status: AiLogStatus.Success,
980 model: options.imageModel,
981 duration: Date.now() - startTime,
982 response,
983 },
984 })
985
986 return
987 }
988
989 // draft 类型:复用已有 materialId 或创建新素材
990 const coverUrl = generatedImageUrls[0]
991 const draftPlan = plan!
992
993 const materialId = existing.materialId ?? (await this.materialRepository.create({
994 userId,
995 userType,
996 groupId,
997 type: MaterialType.ARTICLE,
998 source: MaterialSource.PlaceDraft,
999 status: MaterialStatus.SUCCESS,
1000 title: draftPlan.title,
1001 desc: draftPlan.description,
1002 topics: draftPlan.topics,
1003 coverUrl,
1004 mediaList: generatedImageUrls.map(url => ({ url, type: MediaType.IMG })),
1005 useCount: 0,
1006 autoDeleteMedia: false,
1007 model: options.imageModel,
1008 generationParams: {
1009 model: options.imageModel,
1010 ...options,
1011 },
1012 accountTypes: (options.platforms as AccountType[]) ?? getCompatibleAccountTypes({
1013 type: 'article',
1014 title: draftPlan.title,
1015 desc: draftPlan.description,
1016 topics: draftPlan.topics,
1017 imageCount: generatedImageUrls.length,
1018 aspectRatio: options.aspectRatio,
1019 }),
1020 })).id
1021
1022 const response: ImageTextDraftResponse = {
1023 materialId,
1024 title: draftPlan.title,
1025 description: draftPlan.description,
1026 topics: draftPlan.topics,
1027 coverUrl,
1028 imageUrls: generatedImageUrls,
1029 requestedImageCount: targetImageCount,
1030 generatedImageCount: generatedImageUrls.length,
1031 plan: draftPlan,
1032 }
1033
1034 await this.aiLogRepository.updateById(aiLogId, {
1035 $set: {
1036 status: AiLogStatus.Success,
1037 model: options.imageModel,
1038 duration: Date.now() - startTime,
1039 response,
1040 },
1041 })
1042 }
1043 catch (error) {
1044 this.logger.error(error, `v2 generateContentImageText failed aiLogId=${aiLogId}, userId=${userId}, groupId=${groupId}`)
1045 throw new DraftGenerationError(
1046 getErrorMessage(error),
1047 error,
1048 )
1049 }
1050 }
1051
1052 /**
1053 * 根据模型类型批量生成图片
1054 */
1055 private async generateImages(
1056 userId: string,
1057 userType: UserType,
1058 imageModel: string,
1059 imagePrompts: ImageGenerationPrompt[],
1060 imageExecution: ResolvedImageExecution,
1061 referenceImageUrls: string[],
1062 aspectRatio?: string,
1063 imageSize?: string,
1064 onImageGenerated?: ImageGenerationProgressHandler,
1065 ): Promise<{ urls: string[], imageGenerationErrors: ImageGenerationErrorDetail[] }> {
1066 if (imageExecution.mode === 'gemini') {
1067 return this.generateImagesWithGemini(userId, userType, imageModel, imagePrompts, referenceImageUrls, aspectRatio, imageSize, onImageGenerated)
1068 }
1069
1070 return this.generateImagesWithOpenAI(userId, userType, imageModel, imagePrompts, imageExecution, referenceImageUrls, imageSize, onImageGenerated)
1071 }
1072
1073 /**
1074 * 使用 Gemini 模型(nb2/nb-pro)批量生成图片
1075 */
1076 private async generateImagesWithGemini(
1077 userId: string,
1078 userType: UserType,
1079 model: string,
1080 imagePrompts: ImageGenerationPrompt[],
1081 referenceImageUrls: string[],
1082 aspectRatio?: string,
1083 imageSize?: string,
1084 onImageGenerated?: ImageGenerationProgressHandler,
1085 ): Promise<{ urls: string[], imageGenerationErrors: ImageGenerationErrorDetail[] }> {
1086 const urls: string[] = []
1087 const imageGenerationErrors: ImageGenerationErrorDetail[] = []
1088
1089 for (const { prompt, promptIndex } of imagePrompts) {
1090 this.logger.log(
1091 { model, promptIndex, promptLength: prompt.length, aspectRatio, imageSize },
1092 'ImageText: Generating image with Gemini',
1093 )
1094
1095 try {
1096 const result = await retry(
1097 () => this.imageService.userGeminiGeneration({
1098 userId,
1099 userType,
1100 model: model as 'gemini-3.1-flash-image-preview' | 'gemini-3-pro-image-preview',
1101 prompt,
1102 imageUrls: referenceImageUrls.length > 0 ? referenceImageUrls : undefined,
1103 ...(imageSize ? { imageSize: imageSize as '1K' | '2K' | '4K' } : {}),
1104 ...(aspectRatio ? { aspectRatio: aspectRatio as '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '4:5' | '5:4' | '9:16' | '16:9' | '21:9' } : {}),
1105 }),
1106 {
1107 maxRetries: 3,
1108 delayMs: 1000,
1109 onRetry: (error, attempt) => {
1110 this.logger.warn(
1111 error,
1112 `ImageText: Gemini image generation failed, retrying promptIndex=${promptIndex}, attempt=${attempt}`,
1113 )
1114 },
1115 },
1116 )
1117
1118 const remainingSlots = imagePrompts.length - urls.length
1119 if (remainingSlots <= 0) {
1120 break
1121 }
1122
1123 if (result.images.length > 0) {
1124 const generatedUrls = result.images.slice(0, remainingSlots).map(image => image.url)
1125 for (const imageUrl of generatedUrls) {
1126 urls.push(imageUrl)
1127 await onImageGenerated?.(imageUrl, imageGenerationErrors)
1128 }
1129 }
1130
1131 if (urls.length >= imagePrompts.length) {
1132 break
1133 }
1134 }
1135 catch (error) {
1136 imageGenerationErrors.push({
1137 promptIndex,
1138 errorMessage: error instanceof Error ? error.message : String(error),
1139 })
1140 this.logger.warn(
1141 error,
1142 `ImageText: Failed to generate image after retries, skipping promptIndex=${promptIndex}`,
1143 )
1144 }
1145 }
1146
1147 return { urls, imageGenerationErrors }
1148 }
1149
1150 private async generateImagesWithOpenAI(
1151 userId: string,
1152 userType: UserType,
1153 model: string,
1154 imagePrompts: ImageGenerationPrompt[],
1155 imageExecution: ResolvedImageExecution,
1156 referenceImageUrls: string[],
1157 imageSize?: string,
1158 onImageGenerated?: ImageGenerationProgressHandler,
1159 ): Promise<{ urls: string[], imageGenerationErrors: ImageGenerationErrorDetail[] }> {
1160 const urls: string[] = []
1161 const imageGenerationErrors: ImageGenerationErrorDetail[] = []
1162
1163 for (const { prompt, promptIndex } of imagePrompts) {
1164 this.logger.log(
1165 {
1166 model,
1167 promptIndex,
1168 promptLength: prompt.length,
1169 resolvedSize: imageExecution.resolvedSize,
1170 referenceHandling: imageExecution.referenceHandling,
1171 },
1172 'ImageText: Generating image with standard image service',
1173 )
1174
1175 try {
1176 const result = await retry(
1177 async () => {
1178 if (imageExecution.mode === 'openai-edit') {
1179 if (referenceImageUrls.length === 0) {
1180 throw new Error('ImageText: missing reference images for edit mode')
1181 }
1182
1183 return this.imageService.userEdit({
1184 userId,
1185 userType,
1186 model,
1187 image: referenceImageUrls,
1188 prompt,
1189 n: 1,
1190 size: imageExecution.resolvedSize,
1191 })
1192 }
1193
1194 return this.imageService.userGeneration({
1195 userId,
1196 userType,
1197 model,
1198 prompt,
1199 n: 1,
1200 size: imageExecution.resolvedSize,
1201 })
1202 },
1203 {
1204 maxRetries: 3,
1205 delayMs: 1000,
1206 onRetry: (error, attempt) => {
1207 this.logger.warn(
1208 error,
1209 `ImageText: Standard image generation failed, retrying promptIndex=${promptIndex}, attempt=${attempt}`,
1210 )
1211 },
1212 },
1213 )
1214
1215 const remainingSlots = imagePrompts.length - urls.length
1216 if (remainingSlots <= 0) {
1217 break
1218 }
1219
1220 const generatedUrls = result.list
1221 .map(item => item.url)
1222 .filter((url): url is string => !!url)
1223
1224 if (generatedUrls.length > 0) {
1225 for (const imageUrl of generatedUrls.slice(0, remainingSlots)) {
1226 urls.push(imageUrl)
1227 await onImageGenerated?.(imageUrl, imageGenerationErrors)
1228 }
1229 }
1230
1231 if (urls.length >= imagePrompts.length) {
1232 break
1233 }
1234 }
1235 catch (error) {
1236 imageGenerationErrors.push({
1237 promptIndex,
1238 errorMessage: error instanceof Error ? error.message : String(error),
1239 })
1240 this.logger.warn(
1241 error,
1242 `ImageText: Failed to generate image after retries, skipping promptIndex=${promptIndex}`,
1243 )
1244 }
1245 }
1246
1247 return { urls, imageGenerationErrors }
1248 }
1249
1250 /**
1251 * 视频 URL 生成草稿:使用 Gemini 多模态模型分析视频内容,生成文案并保存草稿
1252 *
1253 * 流程:
1254 * 1. 解析素材组(默认草稿箱)
1255 * 2. Gemini Flash 分析视频 URL → 生成 title / description / topics
1256 * 3. 从视频截帧生成封面
1257 * 4. 保存 Material 草稿
1258 */
1259 async generateDraftFromVideoUrl(
1260 userId: string,
1261 userType: UserType,
1262 dto: CreateDraftFromVideoUrlDto,
1263 ): Promise<{ materialId: string }> {
1264 const resolvedGroupId = await this.resolveDraftGroupId(userId, dto.groupId)
1265 const modelName = 'gemini-3.5-flash'
1266 const startedAt = new Date()
1267 const fullVideoUrl = FileUtil.buildUrl(dto.videoUrl)
1268
1269 const prompt = `You are a TikTok content generation assistant.
1270 ## Task
1271 Watch the video and generate TikTok post metadata based on its content.
1272
1273 ## Instructions
1274 - **title**: Catchy title under 30 characters, in the language that best matches the video content
1275 - **description**: Engaging description with call-to-action, under 2200 characters, in the language matching the video content
1276 - **topics**: 3-5 relevant hashtags (without # prefix)
1277 - **IMPORTANT**: Do NOT generate any content featuring children, minors, or anyone appearing under 18.
1278 Return the result as JSON.`
1279
1280 const model = new ChatGoogleGenerativeAI({
1281 model: modelName,
1282 apiKey: config.ai.gemini.apiKey,
1283 baseUrl: config.ai.gemini.baseUrl,
1284 temperature: 1.0,
1285 })
1286
1287 const messageContent: Array<
1288 { type: 'video', url: string }
1289 | { type: 'text', text: string }
1290 > = [
1291 { type: 'video', url: fullVideoUrl },
1292 { type: 'text', text: prompt },
1293 ]
1294
1295 const structuredModel = model.withStructuredOutput(z.toJSONSchema(VideoDraftMetadataResultSchema), { includeRaw: true })
1296 const { parsed: structuredResult } = await this.aiAvailability.execute(
1297 { provider: 'gemini', operation: 'draftGeneration.generateDraftFromVideoUrl', model: modelName },
1298 async () => structuredModel.invoke([
1299 new HumanMessage({ content: messageContent }),
1300 ]),
1301 )
1302
1303 if (!structuredResult) {
1304 throw new Error('VideoUrl: No response from Gemini')
1305 }
1306
1307 const parsed = z.safeParse(VideoDraftMetadataResultSchema, structuredResult)
1308 if (!parsed.success) {
1309 throw new Error(`VideoUrl: Invalid plan result: ${z.prettifyError(parsed.error)}`)
1310 }
1311
1312 const plan = parsed.data
1313 const duration = Date.now() - startedAt.getTime()
1314 await this.aiLogRepository.create({
1315 userId,
1316 userType,
1317 type: AiLogType.Agent,
1318 model: modelName,
1319 channel: AiLogChannel.Gemini,
1320 startedAt,
1321 duration,
1322 request: { videoUrl: dto.videoUrl },
1323 response: plan,
1324 status: AiLogStatus.Success,
1325 })
1326
1327 this.logger.log({ plan }, 'VideoUrl: Plan generated')
1328
1329 let coverUrl: string | undefined
1330 try {
1331 const thumbnailBuffer = await this.videoMetadataService.extractThumbnailFromUrl(fullVideoUrl, 2)
1332 const uploadResult = await this.assetsService.uploadFromBuffer(userId, thumbnailBuffer, {
1333 type: AssetType.VideoThumbnail,
1334 mimeType: 'image/png',
1335 filename: 'thumbnail.png',
1336 })
1337 coverUrl = uploadResult.asset.path
1338 }
1339 catch (error) {
1340 this.logger.warn(error, `VideoUrl: Failed to extract video thumbnail, proceeding without cover videoUrl=${dto.videoUrl}`)
1341 }
1342
1343 const material = await this.materialRepository.create({
1344 userId,
1345 userType,
1346 groupId: resolvedGroupId,
1347 type: MaterialType.VIDEO,
1348 source: MaterialSource.PlaceDraft,
1349 status: MaterialStatus.SUCCESS,
1350 title: plan.title,
1351 desc: plan.description,
1352 topics: plan.topics,
1353 coverUrl,
1354 mediaList: [{ url: dto.videoUrl, type: MediaType.VIDEO, thumbUrl: coverUrl }],
1355 useCount: 0,
1356 autoDeleteMedia: false,
1357 model: modelName,
1358 generationParams: {
1359 model: modelName,
1360 videoUrl: dto.videoUrl,
1361 },
1362 accountTypes: dto.platforms ?? getCompatibleAccountTypes({
1363 type: 'video',
1364 title: plan.title,
1365 desc: plan.description,
1366 topics: plan.topics,
1367 }),
1368 })
1369
1370 return { materialId: material.id }
1371 }
1372
1373 private async resolveDraftGroupId(userId: string, groupId?: string): Promise<string> {
1374 if (groupId) {
1375 const group = await this.materialGroupRepository.getInfo(groupId)
1376 if (!group || group.userId !== userId) {
1377 throw new AppException(ResponseCode.MaterialGroupNotFound)
1378 }
1379 return group.id
1380 }
1381
1382 const defaultGroup = await this.materialGroupRepository.getDefaultGroup(userId)
1383 if (!defaultGroup) {
1384 throw new AppException(ResponseCode.MaterialGroupNotFound)
1385 }
1386
1387 return defaultGroup.id
1388 }
1389 }
1390
1390 lines TYPESCRIPT