返回 AiToEarn
subtitle.mcp.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / agent / mcp / subtitle.mcp.ts
1 import * as fs from 'node:fs'
2 import * as os from 'node:os'
3 import * as path from 'node:path'
4 import { createSdkMcpServer, McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk'
5 import { Injectable, Logger } from '@nestjs/common'
6 import { AssetsService } from '@yikart/assets'
7 import { UserType } from '@yikart/common'
8 import { AssetType } from '@yikart/mongodb'
9 import { execa } from 'execa'
10 import { z } from 'zod'
11 import { AiAvailabilityService } from '../../ai-availability'
12 import { ChatService } from '../../ai/chat'
13 import { McpServerName } from '../agent.constants'
14 import { successResult, wrapTool } from './mcp.utils'
15
16 const generateSubtitleSchema = z.object({
17 mediaUrl: z.string().describe('Video or audio URL to transcribe'),
18 language: z.string().optional().describe('Target language code, e.g., "zh-CN", "en-US"'),
19 })
20
21 export enum SubtitleToolName {
22 GenerateSubtitle = 'generateSubtitle',
23 }
24
25 interface SubtitleEntry {
26 index: number
27 startTime: number
28 endTime: number
29 text: string
30 }
31
32 @Injectable()
33 export class SubtitleMcp {
34 private readonly logger = new Logger(SubtitleMcp.name)
35
36 constructor(
37 private readonly chatService: ChatService,
38 private readonly assetsService: AssetsService,
39 private readonly aiAvailability: AiAvailabilityService,
40 ) {}
41
42 /**
43 * 使用 FFmpeg 从 URL 提取音频
44 */
45 private async extractAudioWithFFmpeg(mediaUrl: string): Promise<Buffer> {
46 const tempDir = os.tmpdir()
47 const outputPath = path.join(tempDir, `audio-${Date.now()}.aac`)
48
49 try {
50 await execa('ffmpeg', [
51 '-i',
52 mediaUrl,
53 '-vn',
54 '-acodec',
55 'aac',
56 '-b:a',
57 '128k',
58 '-y',
59 outputPath,
60 ])
61
62 return fs.readFileSync(outputPath)
63 }
64 finally {
65 if (fs.existsSync(outputPath)) {
66 fs.unlinkSync(outputPath)
67 }
68 }
69 }
70
71 /**
72 * 构建字幕转录提示词
73 */
74 private buildSubtitlePrompt(language?: string): string {
75 const targetLang = language || 'the original spoken language'
76 return `Transcribe this audio to ${targetLang} with precise timestamps.
77
78 Requirements:
79 1. Each subtitle entry should be 1-2 sentences, max 2 lines
80 2. Timestamps must be accurate to the audio (in milliseconds)
81 3. Use proper punctuation
82 4. Return a JSON object with an "entries" array
83
84 Each entry must have:
85 - index: number (starting from 1)
86 - startTime: number (milliseconds)
87 - endTime: number (milliseconds)
88 - text: string (subtitle text)
89
90 Example output format:
91 {
92 "entries": [
93 { "index": 1, "startTime": 0, "endTime": 2500, "text": "Hello, welcome to our video." },
94 { "index": 2, "startTime": 2600, "endTime": 5000, "text": "Today we will discuss..." }
95 ]
96 }`
97 }
98
99 /**
100 * 将毫秒转换为 SRT 时间格式 (HH:MM:SS,mmm)
101 */
102 private formatSrtTime(ms: number): string {
103 const hours = Math.floor(ms / 3600000)
104 const minutes = Math.floor((ms % 3600000) / 60000)
105 const seconds = Math.floor((ms % 60000) / 1000)
106 const milliseconds = ms % 1000
107
108 return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${milliseconds.toString().padStart(3, '0')}`
109 }
110
111 /**
112 * 从结构化数据生成 SRT 格式字幕
113 */
114 private generateSrtFromEntries(entries: SubtitleEntry[]): string {
115 return entries.map((entry) => {
116 const startTime = this.formatSrtTime(entry.startTime)
117 const endTime = this.formatSrtTime(entry.endTime)
118 return `${entry.index}\n${startTime} --> ${endTime}\n${entry.text}\n`
119 }).join('\n')
120 }
121
122 createGenerateSubtitleTool(userId: string, userType: UserType) {
123 return wrapTool(
124 this.logger,
125 SubtitleToolName.GenerateSubtitle,
126 `Generate SRT subtitles from video or audio using AI transcription.
127
128 Parameters:
129 - mediaUrl: URL of the video or audio file to transcribe
130 - language (optional): Target language code (e.g., "zh-CN", "en-US", "ja-JP")
131
132 Returns the generated SRT subtitle file URL.
133
134 Use this tool when user wants to:
135 - 给视频加字幕 / Add subtitles to video
136 - 生成字幕 / Generate subtitles
137 - 视频转录 / Transcribe video
138 - 提取视频文字 / Extract text from video`,
139 generateSubtitleSchema.shape,
140 async ({ mediaUrl, language }) => {
141 this.logger.log({ mediaUrl, language }, 'Starting subtitle generation')
142
143 // Step 1: 使用 FFmpeg 提取音频
144 this.logger.debug('Extracting audio from media URL')
145 const audioBuffer = await this.extractAudioWithFFmpeg(mediaUrl)
146 this.logger.debug({ size: audioBuffer.length }, 'Audio extracted')
147
148 // Step 2: 构建提示词
149 const prompt = this.buildSubtitlePrompt(language)
150
151 // Step 3: 调用 Gemini 进行转录
152 this.logger.debug('Calling Gemini for transcription')
153 const response = await this.chatService.userGeminiGenerateContent({
154 userId,
155 userType,
156 model: 'gemini-3.5-flash',
157 contents: [{
158 role: 'user',
159 parts: [
160 {
161 inlineData: {
162 mimeType: 'audio/aac',
163 data: audioBuffer.toString('base64'),
164 },
165 },
166 { text: prompt },
167 ],
168 }],
169 config: {
170 responseMimeType: 'application/json',
171 },
172 })
173
174 // Step 4: 解析响应
175 const responseText = response.text
176 if (!responseText) {
177 throw new Error('No response from Gemini')
178 }
179
180 const subtitleData = JSON.parse(responseText) as { entries: SubtitleEntry[] }
181 if (!subtitleData.entries || subtitleData.entries.length === 0) {
182 throw new Error('No subtitle entries in response')
183 }
184
185 this.logger.debug({ entryCount: subtitleData.entries.length }, 'Subtitle entries parsed')
186
187 // Step 5: 生成 SRT 格式
188 const srtContent = this.generateSrtFromEntries(subtitleData.entries)
189
190 // Step 6: 上传 SRT 文件
191 this.logger.debug('Uploading SRT file')
192 const srtBuffer = Buffer.from(srtContent, 'utf-8')
193 const result = await this.assetsService.uploadFromBuffer(userId, srtBuffer, {
194 type: AssetType.Subtitle,
195 mimeType: 'text/plain',
196 filename: `subtitle-${Date.now()}.srt`,
197 })
198
199 return successResult(`Subtitle generated successfully!
200
201 **SRT File URL:** ${result.url}
202 **Entries:** ${subtitleData.entries.length}
203
204 You can use this SRT URL in video editing to add subtitles.`)
205 },
206 this.aiAvailability,
207 )
208 }
209
210 createServer(userId: string, userType: UserType): McpSdkServerConfigWithInstance {
211 return createSdkMcpServer({
212 name: McpServerName.Subtitle,
213 version: '1.0.0',
214 tools: [
215 this.createGenerateSubtitleTool(userId, userType),
216 ],
217 })
218 }
219 }
220
220 lines TYPESCRIPT