返回 AiToEarn
volcengine.utils.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / libs / volcengine / volcengine.utils.ts
1 import type { Ratio, Resolution } from './volcengine.interface'
2 import { Logger } from '@nestjs/common'
3
4 const logger = new Logger('VolcengineUtils')
5
6 /**
7 * 火山引擎视频生成模型文本命令工具类
8 * 用于解析和序列化模型文本命令参数
9 */
10
11 // 模型文本命令参数接口
12 export interface ModelTextCommandParams {
13 /** 视频分辨率,简写 rs */
14 resolution?: Resolution
15 /** 生成视频的宽高比例,简写 rt */
16 ratio?: Ratio
17 /** 生成视频时长,单位:秒,简写 dur */
18 duration?: number
19 /** 帧率,即一秒时间内视频画面数量,简写 fps */
20 framespersecond?: number
21 /** 生成视频是否包含水印,简写 wm */
22 watermark?: boolean
23 /** 种子整数,用于控制生成内容的随机性,简写 seed */
24 seed?: number
25 /** 是否固定摄像头,简写 cf */
26 camerafixed?: boolean
27 }
28
29 // 参数映射表
30 const PARAM_MAP = {
31 resolution: 'rs',
32 ratio: 'rt',
33 duration: 'dur',
34 framespersecond: 'fps',
35 watermark: 'wm',
36 seed: 'seed',
37 camerafixed: 'cf',
38 } as const
39
40 // 反向映射表
41 const REVERSE_PARAM_MAP = Object.fromEntries(
42 Object.entries(PARAM_MAP).map(([key, value]) => [value, key]),
43 ) as Record<string, keyof ModelTextCommandParams>
44
45 /**
46 * 将模型文本命令参数序列化为字符串
47 * @param params 模型文本命令参数对象
48 * @returns 序列化后的命令字符串,如 "--rs 720p --rt 16:9 --dur 5"
49 */
50 export function serializeModelTextCommand(params: ModelTextCommandParams): string {
51 const commands: string[] = []
52
53 for (const [key, value] of Object.entries(params)) {
54 if (value !== undefined && value !== null) {
55 const shortKey = PARAM_MAP[key as keyof ModelTextCommandParams]
56 if (shortKey) {
57 commands.push(`--${shortKey} ${value}`)
58 }
59 }
60 }
61
62 return commands.join(' ')
63 }
64
65 /**
66 * 从文本中解析模型文本命令参数
67 * @param text 包含命令参数的文本,如 "小猫对着镜头打哈欠。 --rs 720p --rt 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false"
68 * @returns 解析结果,包含纯文本内容和参数对象
69 */
70 export function parseModelTextCommand(text: string): {
71 prompt: string
72 params: ModelTextCommandParams
73 } {
74 // 查找命令参数的起始位置
75 const commandMatch = text.match(/\s+--\w+/)
76 if (!commandMatch) {
77 return {
78 prompt: text.trim(),
79 params: {},
80 }
81 }
82
83 const commandStartIndex = commandMatch.index!
84 const prompt = text.substring(0, commandStartIndex).trim()
85 const commandText = text.substring(commandStartIndex).trim()
86
87 // 解析命令参数
88 const params: ModelTextCommandParams = {}
89 const paramRegex = /--([a-z]+)\s+(\S+)/gi
90 let match = paramRegex.exec(commandText)
91
92 while (match !== null) {
93 const [, shortKey, value] = match
94 const fullKey = REVERSE_PARAM_MAP[shortKey]
95
96 if (fullKey) {
97 // 根据参数类型转换值
98 switch (fullKey) {
99 case 'duration':
100 case 'framespersecond':
101 case 'seed':
102 params[fullKey] = Number.parseInt(value, 10)
103 break
104 case 'watermark':
105 case 'camerafixed':
106 params[fullKey] = value.toLowerCase() === 'true'
107 break
108 case 'resolution':
109 case 'ratio':
110 params[fullKey] = value
111 break
112 }
113 }
114 match = paramRegex.exec(commandText)
115 }
116
117 return {
118 prompt,
119 params,
120 }
121 }
122
123 /**
124 * 媒体文件类型枚举
125 */
126 export enum MediaType {
127 Video = 'video',
128 Audio = 'audio',
129 Unknown = 'unknown',
130 }
131
132 /**
133 * 根据文件扩展名判断媒体类型
134 */
135 export function detectMediaType(filename: string): MediaType {
136 const extension = filename.toLowerCase().split('.').pop() || ''
137
138 const videoExtensions = [
139 'mp4',
140 'avi',
141 'mov',
142 'wmv',
143 'flv',
144 'mkv',
145 'webm',
146 'm4v',
147 'mpg',
148 'mpeg',
149 '3gp',
150 'f4v',
151 'rmvb',
152 'ts',
153 'vob',
154 'ogv',
155 ]
156
157 const audioExtensions = [
158 'mp3',
159 'wav',
160 'aac',
161 'm4a',
162 'flac',
163 'ogg',
164 'wma',
165 'opus',
166 'aiff',
167 'ape',
168 'alac',
169 'ac3',
170 'dts',
171 'amr',
172 'mid',
173 'midi',
174 ]
175
176 if (videoExtensions.includes(extension)) {
177 return MediaType.Video
178 }
179
180 if (audioExtensions.includes(extension)) {
181 return MediaType.Audio
182 }
183
184 logger.warn(`无法识别文件类型: ${filename}`)
185 return MediaType.Unknown
186 }
187
188 /**
189 * 验证音视频合并的输入是否合法
190 */
191 export function validateVideoAudioMergeInputs(inputs: Array<{ url?: string, fileName?: string }>): {
192 isValid: boolean
193 error?: string
194 suggestion?: string
195 } {
196 if (inputs.length !== 2) {
197 return {
198 isValid: false,
199 error: `音视频合并需要恰好 2 个输入文件,当前提供了 ${inputs.length} 个`,
200 suggestion: '请提供一个视频文件和一个音频文件',
201 }
202 }
203
204 const [first, second] = inputs
205
206 const firstType = detectMediaType(first.url || first.fileName || '')
207 const secondType = detectMediaType(second.url || second.fileName || '')
208
209 if (firstType === MediaType.Unknown || secondType === MediaType.Unknown) {
210 return {
211 isValid: false,
212 error: '无法识别输入文件的类型',
213 suggestion: '请确保文件名包含正确的扩展名(如 .mp4, .mp3)',
214 }
215 }
216
217 if (firstType === MediaType.Video && secondType === MediaType.Audio) {
218 return {
219 isValid: true,
220 }
221 }
222
223 if (firstType === MediaType.Audio && secondType === MediaType.Video) {
224 return {
225 isValid: false,
226 error: '输入顺序错误:第一个输入应该是视频文件,第二个应该是音频文件',
227 suggestion: '请交换输入顺序,将视频文件放在第一位,音频文件放在第二位',
228 }
229 }
230
231 if (firstType === MediaType.Video && secondType === MediaType.Video) {
232 return {
233 isValid: false,
234 error: '两个输入都是视频文件',
235 suggestion: '如果要合并视频和音频,第二个输入应该是音频文件。如果要拼接视频,请使用不同的 prompt(如"将两个视频拼接在一起")',
236 }
237 }
238
239 if (firstType === MediaType.Audio && secondType === MediaType.Audio) {
240 return {
241 isValid: false,
242 error: '两个输入都是音频文件',
243 suggestion: '音视频合并需要一个视频文件和一个音频文件。请至少提供一个视频文件作为画面来源',
244 }
245 }
246
247 return {
248 isValid: false,
249 error: '未知错误',
250 }
251 }
252
253 /**
254 * 生成优化的音视频合并 Prompt
255 */
256 export function generateOptimizedMergePrompt(inputs: Array<{ url?: string, fileName?: string }>): string {
257 const validation = validateVideoAudioMergeInputs(inputs)
258
259 if (!validation.isValid) {
260 logger.warn(`输入验证失败: ${validation.error}`)
261 logger.warn(`建议: ${validation.suggestion}`)
262 }
263
264 // 返回火山引擎推荐的标准格式
265 return '将第一个视频的画面和第二个音频合成为一个新视频,保留视频画面,使用音频的声音'
266 }
267
268 /**
269 * 音频格式转换建议
270 */
271 export const AUDIO_FORMAT_RECOMMENDATIONS = {
272 wav: {
273 recommended: 'mp3',
274 reason: 'WAV 文件较大,可能存在兼容性问题',
275 command: 'ffmpeg -i input.wav -codec:a libmp3lame -b:a 192k output.mp3',
276 },
277 flac: {
278 recommended: 'mp3',
279 reason: 'FLAC 是无损格式,文件较大',
280 command: 'ffmpeg -i input.flac -codec:a libmp3lame -b:a 192k output.mp3',
281 },
282 ape: {
283 recommended: 'mp3',
284 reason: 'APE 格式兼容性较差',
285 command: 'ffmpeg -i input.ape -codec:a libmp3lame -b:a 192k output.mp3',
286 },
287 wma: {
288 recommended: 'mp3',
289 reason: 'WMA 格式兼容性有限',
290 command: 'ffmpeg -i input.wma -codec:a libmp3lame -b:a 192k output.mp3',
291 },
292 } as const
293
294 /**
295 * 获取音频格式转换建议
296 */
297 export function getAudioFormatRecommendation(filename: string): {
298 needsConversion: boolean
299 recommendation?: string
300 reason?: string
301 command?: string
302 } {
303 const extension = filename.toLowerCase().split('.').pop() || ''
304
305 if (extension in AUDIO_FORMAT_RECOMMENDATIONS) {
306 const rec = AUDIO_FORMAT_RECOMMENDATIONS[extension as keyof typeof AUDIO_FORMAT_RECOMMENDATIONS]
307 return {
308 needsConversion: true,
309 recommendation: rec.recommended,
310 reason: rec.reason,
311 command: rec.command,
312 }
313 }
314
315 // MP3, AAC, M4A 等格式无需转换
316 const goodFormats = ['mp3', 'aac', 'm4a', 'opus']
317 if (goodFormats.includes(extension)) {
318 return {
319 needsConversion: false,
320 }
321 }
322
323 return {
324 needsConversion: false,
325 }
326 }
327
328 /**
329 * 解析火山引擎错误代码并提供友好提示
330 */
331 export function parseVolcengineError(errorCode: string | number, errorMessage?: string): {
332 userMessage: string
333 technicalDetails: string
334 suggestions: string[]
335 } {
336 const code = String(errorCode)
337
338 switch (code) {
339 case '19020005':
340 return {
341 userMessage: '视频处理失败',
342 technicalDetails: 'VEdit 服务内部错误',
343 suggestions: [
344 '检查输入文件格式是否正确(建议视频使用 MP4,音频使用 MP3)',
345 '确认输入顺序:第一个是视频文件,第二个是音频文件',
346 '尝试使用官方文档的原始 prompt:"将输入视频和音频合成到一起"',
347 '检查文件是否已上传完成并处理完毕',
348 '使用 getMediaInfos 检查两个 Vid 的文件类型',
349 ],
350 }
351
352 case '21020004':
353 return {
354 userMessage: 'VEdit 数据验证失败',
355 technicalDetails: errorMessage || 'Track 配置验证不通过',
356 suggestions: [
357 '当前 prompt 可能不适合 VCreative 处理',
358 '建议使用官方文档的标准 prompt:"将输入视频和音频合成到一起"',
359 '检查两个 Vid 是否都是有效的媒体文件',
360 '确认第一个是视频文件,第二个是音频文件',
361 '考虑使用其他方式进行音视频合并',
362 ],
363 }
364
365 case '19020001':
366 return {
367 userMessage: '输入参数错误',
368 technicalDetails: '提供的参数不符合要求',
369 suggestions: [
370 '检查所有必需参数是否提供',
371 '确认参数格式是否正确',
372 '查看 API 文档确认参数要求',
373 ],
374 }
375
376 case '19020002':
377 return {
378 userMessage: '视频文件不存在或无法访问',
379 technicalDetails: '指定的 Vid 不存在或已被删除',
380 suggestions: [
381 '确认视频文件已成功上传',
382 '检查 Vid 是否正确',
383 '等待文件处理完成后再提交任务',
384 ],
385 }
386
387 default:
388 return {
389 userMessage: '处理失败',
390 technicalDetails: errorMessage || `错误代码: ${code}`,
391 suggestions: [
392 '查看详细错误信息',
393 '检查输入文件和参数',
394 '如问题持续,请联系技术支持',
395 ],
396 }
397 }
398 }
399
399 lines TYPESCRIPT