返回 AiToEarn
media.mcp.ts
1 import { createSdkMcpServer, McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk'
2 import { Injectable, Logger } from '@nestjs/common'
3 import { FileUtil, UserType } from '@yikart/common'
4 import { AiLogStatus } from '@yikart/mongodb'
5 import dayjs from 'dayjs'
6 import { z } from 'zod'
7 import { AiAvailabilityService } from '../../ai-availability'
8 import { ImageService } from '../../ai/image'
9 import { GrokVideoService, OpenAIVideoService } from '../../ai/video'
10 import { McpServerName } from '../agent.constants'
11 import { errorResult, successResult, wrapTool } from './mcp.utils'
12
13 const generateMediaSchema = z.object({
14 prompt: z.string(),
15 imageUrls: z.array(z.string()).optional(),
16 imageSize: z.enum(['1K', '2K', '4K']).optional(),
17 aspectRatio: z.enum(['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9']).optional(),
18 model: z.enum(['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview']).optional(),
19 })
20
21 const generateVideoSchema = z.object({
22 prompt: z.string(),
23 input_reference: z.string().optional(),
24 model: z.enum(['sora-2', 'sora-2-pro']).default('sora-2'),
25 seconds: z.enum(['8', '10', '25']).optional(),
26 size: z.enum(['720x1280', '1280x720', '1024x1792', '1792x1024']).optional(),
27 })
28
29 const getMediaStatusSchema = z.object({
30 taskId: z.string(),
31 })
32
33 const createSoraCharacterSchema = z.object({
34 prompt: z.string(),
35 videoUrl: z.string().optional(),
36 taskId: z.string().optional(),
37 timestamps: z.string().regex(/^\d+,\d+$/),
38 })
39
40 const getSoraCharacterSchema = z.object({
41 characterId: z.string(),
42 })
43
44 const generateVideoWithGrokSchema = z.object({
45 prompt: z.string().describe('Video description prompt'),
46 model: z.string().default('grok-imagine-video').describe('Grok video model name'),
47 aspectRatio: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']).default('9:16').describe('Video aspect ratio'),
48 resolution: z.enum(['480p', '720p']).default('720p').describe('Video resolution'),
49 duration: z.number().int().min(1).max(15).optional().describe('Video duration in seconds'),
50 imageUrl: z.string().optional().describe('Reference image URL for image-to-video generation'),
51 })
52
53 const getGrokVideoStatusSchema = z.object({
54 taskId: z.string().describe('Task ID returned from generateVideoWithGrok'),
55 })
56
57 export enum MediaToolName {
58 GenerateImage = 'generateImage',
59 GenerateVideo = 'generateVideo',
60 GetVideoStatus = 'getVideoStatus',
61 CreateSoraCharacter = 'createSoraCharacter',
62 GetSoraCharacter = 'getSoraCharacter',
63 GenerateVideoWithGrok = 'generateVideoWithGrok',
64 GetGrokVideoStatus = 'getGrokVideoStatus',
65 }
66
67 @Injectable()
68 export class MediaMcp {
69 private readonly logger = new Logger(MediaMcp.name)
70
71 constructor(
72 private readonly openaiVideoService: OpenAIVideoService,
73 private readonly imageService: ImageService,
74 private readonly aiAvailability: AiAvailabilityService,
75 private readonly grokVideoService: GrokVideoService,
76 ) { }
77
78 createGenerateImageTool(userId: string, userType: UserType) {
79 return wrapTool(
80 this.logger,
81 MediaToolName.GenerateImage,
82 `Generate an image using Gemini model.
83
84 Parameters:
85 - prompt: Text description of the image
86 - imageUrls (optional): Reference image URLs for editing
87 - imageSize (optional): "1K", "2K", or "4K"
88 - aspectRatio (optional): "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
89 - model (optional): "gemini-3.1-flash-image-preview" (default) or "gemini-3-pro-image-preview"
90
91 Returns the generated image URL(s).`,
92 generateMediaSchema.shape,
93 async ({ prompt, imageUrls = [], imageSize, aspectRatio, model }) => {
94 this.logger.debug(`[generateImage] Starting image generation for user ${userId}`)
95 const startTime = Date.now()
96
97 const result = await this.imageService.userGeminiGeneration({
98 userId,
99 userType,
100 prompt,
101 imageUrls,
102 imageSize,
103 aspectRatio,
104 ...(model ? { model } : {}),
105 })
106
107 const generatedImages = result.images.map(img => ({ ...img, url: FileUtil.buildUrl(img.url!) }))
108
109 const duration = Date.now() - startTime
110 this.logger.debug(`[generateImage] Image generation completed in ${duration}ms for user ${userId}, count: ${generatedImages.length}`)
111
112 const text = generatedImages.map((img, index) => `Image URL ${index + 1}: ${img.url}`).join('\n')
113
114 return {
115 content: [
116 ...generatedImages.map((img, index) => ({
117 type: 'resource_link',
118 uri: img.url,
119 name: `Image ${index + 1}`,
120 } as const)),
121 { type: 'text', text },
122 ],
123 }
124 },
125 this.aiAvailability,
126 )
127 }
128
129 createGenerateVideoTool(userId: string, userType: UserType) {
130 return wrapTool(
131 this.logger,
132 MediaToolName.GenerateVideo,
133 `Generate a video using OpenAI Sora model. Follow the Sora Prompting Guide for best results.
134
135 **IMPORTANT: Use the same language as the user's request for the video prompt.**
136
137 **Model Selection**:
138 - sora-2 (default): 10s 720p video, supports audio/voiceover
139 - sora-2-pro: 25s 1024p video - use when user requests high quality/HD or long shots
140
141 **Sora Prompt Engineering Guidelines**:
142
143 1. **Specificity over Abstraction**: Use concrete details instead of vague descriptions.
144 - Bad: "a beautiful street" / "美丽的街道"
145 - Good: "wet asphalt reflecting neon lights, zebra crossing, steam rising from manholes" / "湿润的沥青反射着霓虹灯光,斑马线清晰可见,井盖冒着蒸汽"
146
147 2. **Establish Visual Style First**: Set the visual tone at the beginning.
148 - Example: "1970s film grain aesthetic" / "1970年代胶片质感", "cinematic documentary style" / "电影纪录片风格"
149
150 3. **Camera Composition**: Specify framing and position.
151 - Framing: wide shot (全景), medium close-up (中近景), extreme close-up (特写)
152 - Position: eye-level (平视), low angle (低角度), aerial view (鸟瞰)
153
154 4. **Depth and Focus**: Describe depth of field.
155 - "shallow depth of field with blurred background" / "浅景深,背景虚化"
156
157 5. **Lighting and Color**: Be specific about light sources and color palette.
158 - "golden hour sunlight casting long shadows" / "黄金时段的阳光投下长长的影子"
159
160 6. **Action and Timing**: One camera movement + one subject action per shot.
161 - "Camera slowly pans left as the woman walks towards the window" / "镜头缓慢向左平移,女子走向窗边"
162
163 7. **Character Consistency**: Include detailed character descriptions for recurring characters.
164
165 **Audio/Voiceover Guidelines (口播/旁白)**:
166 - Sora supports native audio generation with voiceover/narration
167 - Supports: Chinese (中文), English, Japanese (日本語), etc.
168 - Describe narration as a separate element at the end of the prompt
169
170 **Voiceover Prompt Format**:
171 [Scene description] + [Camera] + [Atmosphere] + [Action] + [Voiceover content]
172
173 **Examples**:
174 Chinese: "纪录片场景:海浪拍打礁石。全景,稳定镜头。平静氛围。男声旁白:'大自然的力量无处不在'"
175 English: "Product showcase of smartphone on white surface. Close-up, slow rotation. Minimalist aesthetic. Female voiceover: 'The new design features a seamless display'"
176
177 Returns task ID for status tracking.`,
178 generateVideoSchema.shape,
179 async ({ prompt, input_reference, model, seconds, size }) => {
180 const finalSeconds = seconds || (model === 'sora-2-pro' ? '25' : '10')
181 const finalSize = size || (model === 'sora-2-pro' ? '1024x1792' : '720x1280')
182
183 const response = await this.openaiVideoService.createVideo({
184 userId,
185 userType,
186 prompt,
187 input_reference,
188 model,
189 seconds: finalSeconds,
190 size: finalSize,
191 })
192 const { id, error, status } = response
193
194 if (status === AiLogStatus.Failed) {
195 return errorResult(`Failed to generate video with OpenAI ${model}, Error: ${error || 'Unknown error'}`)
196 }
197
198 return successResult(`Video is generating with OpenAI ${model}, task id: ${id}`)
199 },
200 this.aiAvailability,
201 )
202 }
203
204 createGetVideoStatusTool(userId: string, userType: UserType) {
205 return wrapTool(
206 this.logger,
207 MediaToolName.GetVideoStatus,
208 'Get video generation task status. Provide taskId. Returns task status, progress percentage, and video URL when completed. Response includes start time, current time, and elapsed time for tracking generation progress.',
209 getMediaStatusSchema.shape,
210 async ({ taskId }) => {
211 const result = await this.openaiVideoService.getVideo(userId, userType, taskId)
212
213 const startTime = result.created_at || 0
214 let timeInfo = ''
215 if (startTime > 0) {
216 const start = dayjs.unix(startTime)
217 const current = dayjs()
218 const elapsedSeconds = current.diff(start, 'second')
219 const elapsedMinutes = current.diff(start, 'minute')
220 const remainingSeconds = elapsedSeconds % 60
221 timeInfo = `\nStart time: ${start.toISOString()}\nCurrent time: ${current.toISOString()}\nElapsed time: ${elapsedMinutes} minutes ${remainingSeconds} seconds`
222 }
223
224 if (result.status === 'completed' && (result.url || result.video_url)) {
225 const videoUrl = result.url || result.video_url || ''
226 const fullVideoUrl = FileUtil.buildUrl(videoUrl)
227 return successResult(`Video is completed, task id: ${taskId} and video url is ${fullVideoUrl}${timeInfo}`)
228 }
229 if (result.status === 'failed') {
230 return errorResult(`Video is failed, task id: ${taskId} and error message is ${result.error?.message || 'Unknown error'}${timeInfo}`)
231 }
232 return successResult(`Video is ${result.status}, progress: ${result.progress}%, task id: ${taskId}${timeInfo}`)
233 },
234 this.aiAvailability,
235 )
236 }
237
238 createSoraCharacterTool(userId: string, userType: UserType) {
239 return wrapTool(
240 this.logger,
241 MediaToolName.CreateSoraCharacter,
242 `Create a reusable character from video for Sora generation.
243
244 **Purpose**: Maintain consistent character appearance across multiple video generations. Once created, the same character can be referenced in different videos with identical visual features.
245
246 **Creation Methods**:
247 1. From Video URL: Provide videoUrl parameter
248 2. From Task ID: Provide taskId parameter (existing Sora task)
249
250 **timestamps**: Two numbers separated by comma (e.g., "1,3"), gap must be ≤ 3 seconds
251
252 **Processing Time**: Character creation takes approximately 400 seconds (~7 minutes)
253
254 **Usage**: After creation, use @{username} in prompts to reference character
255 Example: "@character1 walks through a garden"`,
256 createSoraCharacterSchema.shape,
257 async ({ prompt, videoUrl, taskId, timestamps }) => {
258 const response = await this.openaiVideoService.createCharacter({
259 userId,
260 userType,
261 prompt,
262 videoUrl,
263 taskId,
264 timestamps,
265 })
266
267 if (response.status === 'failed') {
268 return errorResult(`Failed to create character, Error: ${response.error?.message || 'Unknown error'}`)
269 }
270
271 return successResult(`Character is creating, character id: ${response.id}, use @${response.username} to reference in prompts`)
272 },
273 this.aiAvailability,
274 )
275 }
276
277 createGetSoraCharacterTool(userId: string, userType: UserType) {
278 return wrapTool(
279 this.logger,
280 MediaToolName.GetSoraCharacter,
281 'Get Sora character creation status. Provide characterId. Returns character status and username for referencing in prompts.',
282 getSoraCharacterSchema.shape,
283 async ({ characterId }) => {
284 const result = await this.openaiVideoService.getCharacter(userId, userType, characterId)
285
286 if (result.status === 'completed') {
287 return successResult(`Character is ready, character id: ${result.id}, username: @${result.username}. Use @${result.username} in video prompts to reference this character.`)
288 }
289 if (result.status === 'failed') {
290 return errorResult(`Character creation failed, character id: ${result.id}, error: ${result.error?.message || 'Unknown error'}`)
291 }
292 return successResult(`Character is ${result.status}, character id: ${result.id}`)
293 },
294 this.aiAvailability,
295 )
296 }
297
298 createGenerateVideoWithGrokTool(userId: string, userType: UserType) {
299 return wrapTool(
300 this.logger,
301 MediaToolName.GenerateVideoWithGrok,
302 `Generate a video using Grok video model.
303
304 **IMPORTANT: Use the same language as the user's request for the video prompt.**
305
306 **Model**: grok-imagine-video (default)
307
308 **Generation Modes**:
309 - Text-to-video: Only prompt
310 - Image-to-video: prompt + imageUrl
311
312 **Parameters**:
313 - prompt: Detailed video description
314 - model: Model name (default: grok-imagine-video)
315 - aspectRatio: "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3" (default: 9:16)
316 - resolution: "480p", "720p" (default: 720p)
317 - duration: Video duration in seconds (1-15, optional)
318 - imageUrl: Reference image URL for image-to-video generation (optional)
319
320 Returns task ID for status tracking.`,
321 generateVideoWithGrokSchema.shape,
322 async ({ prompt, model, aspectRatio, resolution, duration, imageUrl }) => {
323 const response = await this.grokVideoService.createVideo({
324 userId,
325 userType,
326 prompt,
327 model,
328 aspectRatio,
329 resolution,
330 duration,
331 image: imageUrl,
332 })
333
334 return successResult(`Video is generating with Grok ${model}, task id: ${response.id}`)
335 },
336 this.aiAvailability,
337 )
338 }
339
340 createGetGrokVideoStatusTool(userId: string, userType: UserType) {
341 return wrapTool(
342 this.logger,
343 MediaToolName.GetGrokVideoStatus,
344 'Get Grok video generation task status. Provide taskId. Returns task status and video URL when completed.',
345 getGrokVideoStatusSchema.shape,
346 async ({ taskId }) => {
347 const result = await this.grokVideoService.getTask(userId, userType, taskId)
348
349 if (result.videoUrl) {
350 return successResult(`Video is completed, task id: ${taskId} and video url is ${FileUtil.buildUrl(result.videoUrl)}`)
351 }
352 if (result.error) {
353 return errorResult(`Video failed, task id: ${taskId} and error message is ${result.error}`)
354 }
355 return successResult(`Video is ${result.status}, task id: ${taskId}`)
356 },
357 this.aiAvailability,
358 )
359 }
360
361 createServer(userId: string, userType: UserType): McpSdkServerConfigWithInstance {
362 return createSdkMcpServer({
363 name: McpServerName.MediaGeneration,
364 version: '1.0.0',
365 tools: [
366 this.createGenerateImageTool(userId, userType),
367 // this.createGenerateVideoTool(userId, userType),
368 // this.createGetVideoStatusTool(userId, userType),
369 // this.createSoraCharacterTool(userId, userType),
370 // this.createGetSoraCharacterTool(userId, userType),
371 this.createGenerateVideoWithGrokTool(userId, userType),
372 this.createGetGrokVideoStatusTool(userId, userType),
373 ],
374 })
375 }
376 }
377
377 lines TYPESCRIPT